Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 | |||
| 15b3a424bc | |||
| 6cb309b6d9 | |||
| f0ceb750a8 | |||
| 6e95defcf5 | |||
| 92c2e8a03b | |||
| 230e5be2fd | |||
| 0331cb976a | |||
| 90cf65e920 | |||
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 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 | |||
| 59ae30897b | |||
| 94a7e07410 | |||
| a5de279bb4 | |||
| d8b6f6e7a3 | |||
| 208762f0d1 | |||
| b076498219 | |||
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc |
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
|||||||
secrets.json
|
secrets.json
|
||||||
secrets.js
|
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)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
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." }
|
{ "results": true, "message": "Added user uid to group group." }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Returns `409` if the user is already a member — common in practice, since
|
||||||
|
`groupOfNames` requires at least one member and so seeds whoever created the
|
||||||
|
group into it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Remove User from Group
|
### Remove User from Group
|
||||||
@@ -716,6 +720,66 @@ The authenticated user is automatically set as the group owner.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Nest a Group Inside Another
|
||||||
|
|
||||||
|
**`PUT /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
Makes `:child` a member of `:group`, so everyone in `:child` is a member of
|
||||||
|
`:group` at any depth.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `400` | `:group` and `:child` are the same group |
|
||||||
|
| `409` | already nested, or the nesting would create a loop (`:child` already contains `:group`, directly or transitively) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Un-nest a Group
|
||||||
|
|
||||||
|
**`DELETE /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `409` | `:child` is the only member — `groupOfNames` requires at least one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Effective Membership
|
||||||
|
|
||||||
|
**`GET /api/group/:group/effective`** — Any authenticated user
|
||||||
|
|
||||||
|
Who a group actually grants. `direct` is users listed on the group itself
|
||||||
|
(never groups); `nestedGroups` is what is nested into it; `effective` is every
|
||||||
|
user reachable through the whole chain.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"cn": "app_gitea_access",
|
||||||
|
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
|
||||||
|
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
|
||||||
|
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Delete Group
|
### Delete Group
|
||||||
|
|
||||||
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
||||||
@@ -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
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
+432
@@ -1,9 +1,432 @@
|
|||||||
|
## v1.19.0
|
||||||
|
- Added WebSocket endpoint for theta-agent C2
|
||||||
|
|
||||||
|
# v1.18.0
|
||||||
|
- feat: Add messaging plugins, Docker discovery, fix reconciliation
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.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
|
||||||
|
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
|
||||||
|
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
|
||||||
|
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
|
||||||
|
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
|
||||||
|
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
|
||||||
|
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
|
||||||
|
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
|
||||||
|
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
|
||||||
|
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
|
||||||
|
|
||||||
|
### Fixed (sso-manager-node)
|
||||||
|
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
|
||||||
|
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
|
||||||
|
|
||||||
|
### Added (sso-manager-node)
|
||||||
|
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-07-25
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
|
||||||
|
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
|
||||||
|
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
|
||||||
|
- `@simpleworkjs/ldap` — `models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
|
||||||
|
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
|
||||||
|
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
|
||||||
|
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
|
||||||
|
|
||||||
## [1.3.2] - 2026-07-23
|
## [1.3.2] - 2026-07-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -195,6 +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.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
|
||||||
|
- Added Discovery plugin and Scheduler integration within the Directory.
|
||||||
|
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
||||||
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
||||||
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
||||||
|
|||||||
-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; \
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
||||||
|
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
||||||
|
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||||
|
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||||
|
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||||
|
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||||
|
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
||||||
|
# where any consumer that forgets silently under-grants access.
|
||||||
|
#
|
||||||
|
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
||||||
|
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||||
|
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||||
|
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||||
|
FROM node:20-alpine AS ldapbuild
|
||||||
|
|
||||||
|
# groff is not optional despite producing nothing we ship: the build descends
|
||||||
|
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||||
|
# provides. Without it the whole `make` fails at the man-page stage
|
||||||
|
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base autoconf automake libtool \
|
||||||
|
openssl-dev cyrus-sasl-dev \
|
||||||
|
git make pkgconf util-linux-dev groff
|
||||||
|
|
||||||
|
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
||||||
|
# whole lab authenticates against; an unpinned `master` would mean every image
|
||||||
|
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
||||||
|
# master would take out logins with no way to tell what changed.
|
||||||
|
#
|
||||||
|
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
||||||
|
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
||||||
|
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
||||||
|
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
||||||
|
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
||||||
|
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git init -q . \
|
||||||
|
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||||
|
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||||
|
&& git checkout -q FETCH_HEAD \
|
||||||
|
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||||
|
|
||||||
|
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||||
|
# `moduleload`s them individually; nestgroup joins that set.
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/opt/openldap \
|
||||||
|
--enable-slapd \
|
||||||
|
--enable-modules \
|
||||||
|
--enable-mdb \
|
||||||
|
--enable-memberof=mod \
|
||||||
|
--enable-refint=mod \
|
||||||
|
--enable-ppolicy=mod \
|
||||||
|
--enable-dynlist=mod \
|
||||||
|
--enable-nestgroup=mod \
|
||||||
|
--enable-syncprov=mod \
|
||||||
|
--enable-auditlog=mod \
|
||||||
|
--with-tls=openssl \
|
||||||
|
--with-cyrus-sasl \
|
||||||
|
&& make depend \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||||
|
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||||
|
# built separately against the just-built tree -- omitting it would make every
|
||||||
|
# user password unverifiable.
|
||||||
|
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||||
|
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||||
|
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenLDAP and required packages.
|
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||||
# the app depends on:
|
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||||
# openldap-overlay-memberof : reverse group membership
|
# symbol not found", not at build time.
|
||||||
# openldap-overlay-refint : referential integrity on group members
|
|
||||||
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
|
|
||||||
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
|
|
||||||
# schema is built into ppolicy.so and registered when the module loads, so
|
|
||||||
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
|
|
||||||
# openssl : used by docker-entrypoint.sh to generate a JWT secret
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openldap \
|
openssl \
|
||||||
openldap-clients \
|
libsasl \
|
||||||
openldap-back-mdb \
|
libltdl \
|
||||||
openldap-overlay-ppolicy \
|
libuuid \
|
||||||
openldap-overlay-memberof \
|
|
||||||
openldap-overlay-refint \
|
|
||||||
openldap-overlay-syncprov \
|
|
||||||
openldap-overlay-auditlog \
|
|
||||||
openldap-passwd-sha2 \
|
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
openssl \
|
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
# Which upstream commit this slapd was built from — so a running container can
|
||||||
|
# answer "what am I actually running" without rebuilding.
|
||||||
|
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
||||||
|
|
||||||
|
# The Alpine openldap package used to create these; nothing does now, and
|
||||||
|
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
||||||
|
RUN addgroup -S ldap 2>/dev/null || true \
|
||||||
|
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
||||||
|
|
||||||
|
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
||||||
|
# and probes a list of candidate module directories, so putting the from-source
|
||||||
|
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
||||||
|
# into the conventional location because the entrypoint's slapd.conf includes
|
||||||
|
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
||||||
|
# openssh-lpk) are copied there too.
|
||||||
|
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
||||||
|
RUN mkdir -p /etc/openldap/schema \
|
||||||
|
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
||||||
|
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
||||||
|
done
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -90,6 +170,7 @@ COPY nodejs/services ./services
|
|||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
|
||||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||||
@@ -101,10 +182,8 @@ COPY tos.md /tos.md
|
|||||||
# without internet access. Same flattened-path convention as tos.md above.
|
# without internet access. Same flattened-path convention as tos.md above.
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
COPY docs /docs
|
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ RUN mkdir -p /app/config
|
|||||||
COPY tos.md /tos.md
|
COPY tos.md /tos.md
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
COPY docs /docs
|
|
||||||
|
|
||||||
# Seed script and utility
|
# Seed script and utility
|
||||||
COPY test_seed.js ./test_seed.js
|
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
|
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
[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
|
## 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 ────────────────────────────────────
|
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||||
# path varies by distro; auto-detect rather than hardcode.
|
# path varies by distro; auto-detect rather than hardcode.
|
||||||
|
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
||||||
|
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
||||||
MODULE_PATH=""
|
MODULE_PATH=""
|
||||||
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||||
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nested-group support is only available when slapd was built with the
|
||||||
|
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
||||||
|
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
||||||
|
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
||||||
|
NESTGROUP_AVAILABLE=0
|
||||||
|
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
||||||
|
NESTGROUP_AVAILABLE=1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
||||||
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||||
@@ -140,6 +151,7 @@ moduleload ppolicy
|
|||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
moduleload auditlog
|
moduleload auditlog
|
||||||
|
NESTGROUP_MODULE_PLACEHOLDER
|
||||||
SYNCPROV_MODULE_PLACEHOLDER
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
@@ -188,6 +200,8 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||||
|
|
||||||
# auditlog overlay (LDIF audit trail of all changes)
|
# auditlog overlay (LDIF audit trail of all changes)
|
||||||
overlay auditlog
|
overlay auditlog
|
||||||
auditlog /var/lib/ldap/auditlog.ldif
|
auditlog /var/lib/ldap/auditlog.ldif
|
||||||
@@ -221,6 +235,37 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Nested groups (nestgroup overlay) ──
|
||||||
|
# Three of the four flags, deliberately:
|
||||||
|
#
|
||||||
|
# member-filter (member=X) finds parent groups transitively. This is what
|
||||||
|
# Group.list(dn) rides on -- the core access question.
|
||||||
|
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
||||||
|
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
||||||
|
# nesting without any client-side walking.
|
||||||
|
# memberof-values expands memberOf when reading a user, so anything that
|
||||||
|
# reads the attribute rather than searching still sees the
|
||||||
|
# full picture.
|
||||||
|
#
|
||||||
|
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
||||||
|
# when reading a *group*, which sounds symmetric but destroys the distinction
|
||||||
|
# between "listed on this group" and "reachable through a nested one" -- and
|
||||||
|
# that distinction is not recoverable afterwards, because the raw values are
|
||||||
|
# simply not returned. The Groups UI needs it to show nested groups as nested
|
||||||
|
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
||||||
|
# it is actually removing. Transitive *answers* come from the filter flags and
|
||||||
|
# from Group.effectiveMembers(), which computes the closure explicitly.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
info "nestgroup overlay available — nested groups resolved server-side"
|
||||||
|
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
||||||
|
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
||||||
|
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
||||||
|
else
|
||||||
|
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
||||||
|
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Multi-Master Replication Configuration ──
|
# ── Multi-Master Replication Configuration ──
|
||||||
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||||
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||||
@@ -310,7 +355,7 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -321,6 +366,27 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
||||||
|
# hold those rights by membership rather than by a special case in app code.
|
||||||
|
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
||||||
|
# anything binding LDAP directly -- instead of only to callers that happen
|
||||||
|
# to route through utils/permission.js.
|
||||||
|
#
|
||||||
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
|
# non-person accounts, not a permission, and nesting admins into it would
|
||||||
|
# misclassify them as service accounts on the Users page.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||||
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||||
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
info "Nested app_super_admin into the SSO admin groups"
|
||||||
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
else
|
else
|
||||||
info "LDAP directory already initialized — skipping seed"
|
info "LDAP directory already initialized — skipping seed"
|
||||||
@@ -380,6 +446,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
|||||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
||||||
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
||||||
|
# Tell the app whether slapd resolves nested groups for it. When true the app
|
||||||
|
# trusts a plain (member=) search to be transitive; when false it computes
|
||||||
|
# the closure itself. Getting this wrong in the "true" direction silently
|
||||||
|
# under-grants, so it is derived from the same nestgroup.so probe that
|
||||||
|
# decides whether the overlay is configured at all -- never hardcoded.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
||||||
|
fi
|
||||||
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||||
|
|||||||
@@ -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,97 +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.
|
|
||||||
|
|
||||||
## 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,82 +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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- **[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
|
|
||||||
+41
-4
@@ -43,6 +43,9 @@ app.onListen.push(function(){
|
|||||||
// socket.broadcast.emit('P2PSub', msg);
|
// 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,
|
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||||
@@ -61,6 +64,11 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
// Set as an app local so every res.render has it, including routes that don't
|
||||||
|
// spread the routers' `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
@@ -86,9 +94,13 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
|
// Self-service access requests — any authenticated user may ask; deciding is
|
||||||
|
// gated per-resource inside the router (owner or directory admin).
|
||||||
|
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||||
|
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
@@ -97,7 +109,22 @@ app.use('/oauth', oauthRouter);
|
|||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
|
// 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
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
@@ -108,9 +135,6 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discovery API
|
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
|
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
@@ -123,5 +147,18 @@ app.use(function(err, req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(err.status || 500);
|
res.status(err.status || 500);
|
||||||
res.json({name: err.name, message: err.message});
|
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('./utils/build_info');
|
||||||
|
res.render('error', {
|
||||||
|
name: conf.name,
|
||||||
|
title: 'Error',
|
||||||
|
titleIcon: '',
|
||||||
|
logo: conf.logo,
|
||||||
|
error: err,
|
||||||
|
...buildInfo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({name: err.name, message: err.message});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,15 +25,41 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
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');
|
const models = require('../models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
models.initORM().then(() => {
|
models.initORM().then(() => {
|
||||||
|
// 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.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
server.on('listening', onListening);
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
// Initialize scheduler
|
||||||
|
const { initScheduler } = require('../services/scheduler');
|
||||||
|
initScheduler(conf.discovery).catch(err => {
|
||||||
|
console.error('Failed to initialize scheduler:', err);
|
||||||
|
});
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ module.exports = {
|
|||||||
// public 636 port forward. See docs/ldap.md.
|
// public 636 port forward. See docs/ldap.md.
|
||||||
ldapsHost: '',
|
ldapsHost: '',
|
||||||
ldapsPort: 636,
|
ldapsPort: 636,
|
||||||
|
// True when slapd carries the `nestgroup` overlay, which resolves nested
|
||||||
|
// groups server-side. Set automatically by docker-entrypoint.sh for the
|
||||||
|
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
|
||||||
|
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
|
||||||
|
nestedGroupsServerSide: false,
|
||||||
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
@@ -60,6 +65,16 @@ module.exports = {
|
|||||||
pass: '__in secrets file__',
|
pass: '__in secrets file__',
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
from: 'SSO Manager <noreply@example.com>',
|
||||||
},
|
},
|
||||||
|
directory: {
|
||||||
|
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||||
|
// component). When set, a host card in the catalog shows the real
|
||||||
|
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
|
||||||
|
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
|
||||||
|
// the card falls back to the direct form.
|
||||||
|
jumpHost: '',
|
||||||
|
// Default SSH port assumed when a host carries no metadata.sshPort.
|
||||||
|
defaultSshPort: 22,
|
||||||
|
},
|
||||||
service: {
|
service: {
|
||||||
updateCheck: {
|
updateCheck: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
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 };
|
||||||
+189
-37
@@ -3,44 +3,18 @@
|
|||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const { LRUCache } = require('lru-cache');
|
const { LRUCache } = require('lru-cache');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||||
// Escape a value used inside an LDAP search filter (RFC 4515).
|
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||||
function escapeLDAPSearchValue(val) {
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
return String(val)
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
.replace(/\\/g, '\\5c')
|
const escapeLDAPDNValue = escapeDN;
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
|
|
||||||
// are normally alphanumeric, but this prevents metacharacter injection.
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroups(client, member){
|
async function getGroups(client, member){
|
||||||
@@ -138,18 +112,190 @@ async function cachedListDetail() {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nested groups -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
|
||||||
|
// a group DN is a perfectly legal member. That is how nesting is stored here:
|
||||||
|
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
|
||||||
|
// exactly as an admin entered it.
|
||||||
|
//
|
||||||
|
// What LDAP will NOT do is resolve it. The memberof overlay records only
|
||||||
|
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
|
||||||
|
// groups that list the DN literally. So transitivity is computed here, and
|
||||||
|
// every membership question in the app must go through these helpers or it
|
||||||
|
// will silently see one level and grant nothing for a nested group.
|
||||||
|
//
|
||||||
|
// The whole group set is one subtree search, so the closure is computed in
|
||||||
|
// memory rather than issuing a query per level. `resolverCache` keeps that
|
||||||
|
// search off the hot path for bursts; it is cleared by every write below, so
|
||||||
|
// the only staleness it can introduce is from edits made outside this app.
|
||||||
|
// Auth decisions ride on this, hence the deliberately short TTL.
|
||||||
|
|
||||||
|
const NESTING_TTL_MS = 15 * 1000;
|
||||||
|
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
|
||||||
|
|
||||||
|
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
|
||||||
|
|
||||||
|
async function allGroupsForResolver() {
|
||||||
|
const hit = resolverCache.get('all');
|
||||||
|
if (hit) return hit;
|
||||||
|
const promise = withClient(async (client) => {
|
||||||
|
const groups = await getGroups(client);
|
||||||
|
return groups.map(g => ({ ...g }));
|
||||||
|
}).then(plain => {
|
||||||
|
resolverCache.set('all', plain);
|
||||||
|
return plain;
|
||||||
|
}).catch(err => {
|
||||||
|
resolverCache.delete('all');
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
resolverCache.set('all', promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lc = dn => String(dn || '').toLowerCase();
|
||||||
|
|
||||||
|
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
|
||||||
|
function buildParentIndex(groups) {
|
||||||
|
const parents = new Map();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const member of [].concat(group.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
if (!parents.has(key)) parents.set(key, []);
|
||||||
|
parents.get(key).push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every group `dn` belongs to, directly or through any chain of nested groups.
|
||||||
|
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
|
||||||
|
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
|
||||||
|
function closureUp(dn, groups) {
|
||||||
|
const parents = buildParentIndex(groups);
|
||||||
|
const found = new Map(); // cn -> group
|
||||||
|
const seen = new Set([lc(dn)]);
|
||||||
|
let frontier = [lc(dn)];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const group of parents.get(current) || []) {
|
||||||
|
const groupDn = lc(group.dn);
|
||||||
|
if (seen.has(groupDn)) continue;
|
||||||
|
seen.add(groupDn);
|
||||||
|
found.set(group.cn, group);
|
||||||
|
// The group itself is now a member to look up: this is the step
|
||||||
|
// that makes the walk transitive rather than one-level.
|
||||||
|
next.push(groupDn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return [...found.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every member DN reachable from a group, split into the users it effectively
|
||||||
|
// grants and the groups it nests. `direct` is kept separate so the UI can show
|
||||||
|
// "3 members, 12 effective" and so removal stays unambiguous.
|
||||||
|
function closureDown(group, groups) {
|
||||||
|
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
|
||||||
|
const users = new Set();
|
||||||
|
const nested = new Map();
|
||||||
|
const seen = new Set([lc(group.dn)]);
|
||||||
|
let frontier = [group];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const member of [].concat(current.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
const asGroup = byDn.get(key);
|
||||||
|
if (asGroup) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
nested.set(asGroup.cn, asGroup);
|
||||||
|
next.push(asGroup);
|
||||||
|
} else {
|
||||||
|
users.add(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return { users: [...users], nested: [...nested.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
var Group = {};
|
var Group = {};
|
||||||
|
|
||||||
|
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
|
||||||
|
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
|
||||||
|
// a plain `(member=<dn>)` search already returns the full transitive set and the
|
||||||
|
// in-app closure is redundant work on every request. Without it -- e.g. pointed
|
||||||
|
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
|
||||||
|
// compute the closure itself or nested groups silently grant nothing.
|
||||||
|
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
|
||||||
|
|
||||||
|
// Transitive: every group CN this member belongs to, at any nesting depth.
|
||||||
|
// Callers making an access decision must use this rather than reading
|
||||||
|
// `memberOf`, which a server without nestgroup only ever populates one level
|
||||||
|
// deep.
|
||||||
Group.list = async function(member){
|
Group.list = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => {
|
if (SERVER_SIDE_NESTING) {
|
||||||
const groups = await getGroups(client, member);
|
return withClient(async (client) => {
|
||||||
return groups.map(group => group.cn);
|
const groups = await getGroups(client, member);
|
||||||
});
|
return groups.map(group => group.cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
return closureUp(member, groups).map(group => group.cn);
|
||||||
}
|
}
|
||||||
return (await cachedListDetail()).map(group => group.cn);
|
return (await cachedListDetail()).map(group => group.cn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The members a group effectively grants: users reached through any chain of
|
||||||
|
// nested groups, plus the nested groups themselves for display.
|
||||||
|
Group.effectiveMembers = async function(cn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const group = groups.find(g => g.cn === cn);
|
||||||
|
if (!group) {
|
||||||
|
let error = new Error('GroupNotFound');
|
||||||
|
error.name = 'GroupNotFound';
|
||||||
|
error.message = `LDAP:${cn} does not exists`;
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { users, nested } = closureDown(group, groups);
|
||||||
|
const directMembers = [].concat(group.member || []).filter(Boolean);
|
||||||
|
const groupDns = new Set(groups.map(g => lc(g.dn)));
|
||||||
|
return {
|
||||||
|
cn: group.cn,
|
||||||
|
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
|
||||||
|
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
|
||||||
|
effective: users,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
|
||||||
|
// itself, nor anything that already (transitively) contains it -- such a chain
|
||||||
|
// makes membership unanswerable, and callers would rely on the depth cap to
|
||||||
|
// stop rather than getting a real answer.
|
||||||
|
Group.wouldCycle = async function(parentCn, childDn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const parent = groups.find(g => g.cn === parentCn);
|
||||||
|
if (!parent) return false;
|
||||||
|
if (lc(parent.dn) === lc(childDn)) return true;
|
||||||
|
const child = groups.find(g => lc(g.dn) === lc(childDn));
|
||||||
|
if (!child) return false; // a user DN can never close a cycle
|
||||||
|
// Adding child under parent is a cycle exactly when parent is already
|
||||||
|
// reachable downward from child.
|
||||||
|
const { nested } = closureDown(child, groups);
|
||||||
|
return nested.some(g => lc(g.dn) === lc(parent.dn));
|
||||||
|
};
|
||||||
|
|
||||||
|
Group.clearResolverCache = function(){ resolverCache.clear(); };
|
||||||
|
|
||||||
Group.listDetail = async function(member){
|
Group.listDetail = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => getGroups(client, member));
|
return withClient(async (client) => getGroups(client, member));
|
||||||
@@ -192,6 +338,7 @@ Group.add = async function(data){
|
|||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
await addGroup(client, data);
|
await addGroup(client, data);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this.get(data);
|
return this.get(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,6 +347,7 @@ Group.addMember = async function(user){
|
|||||||
await withClient(async (client) => addMember(client, this, user));
|
await withClient(async (client) => addMember(client, this, user));
|
||||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +360,7 @@ Group.removeMember = async function(user){
|
|||||||
}
|
}
|
||||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,6 +368,7 @@ Group.addOwner = async function(user){
|
|||||||
await withClient(async (client) => addOwner(client, this, user));
|
await withClient(async (client) => addOwner(client, this, user));
|
||||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,12 +381,14 @@ Group.removeOwner = async function(user){
|
|||||||
}
|
}
|
||||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
Group.remove = async function(){
|
Group.remove = async function(){
|
||||||
await withClient(async (client) => client.del(this.dn));
|
await withClient(async (client) => client.del(this.dn));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ require('./api_token');
|
|||||||
|
|
||||||
const { init } = require('@simpleworkjs/orm');
|
const { init } = require('@simpleworkjs/orm');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
|
const { AccessRequest } = require('./access_request');
|
||||||
|
const { Webhook } = require('./webhook');
|
||||||
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -28,7 +30,7 @@ async function initORM() {
|
|||||||
await init({
|
await init({
|
||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// PluginInstance — the registry of configured, loadable plugin copies.
|
||||||
|
//
|
||||||
|
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
|
||||||
|
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
|
||||||
|
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
|
||||||
|
// type. You can have several instances of the same type (e.g. two Proxmox
|
||||||
|
// endpoints with their own URLs + tokens), each on its own schedule.
|
||||||
|
//
|
||||||
|
// This table holds the *non-secret* per-instance state: which type it is, its
|
||||||
|
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
|
||||||
|
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
|
||||||
|
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
|
||||||
|
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
|
||||||
|
// the DB. The DB row's `config` JSON column holds only non-secret field values.
|
||||||
|
//
|
||||||
|
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
|
||||||
|
// so a discovery instance's resources are attributed to a stable, human-chosen
|
||||||
|
// name rather than its uuid. Unique, so two instances can't shadow each other
|
||||||
|
// in the resource graph's `discovery_sources`.
|
||||||
|
//
|
||||||
|
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
|
||||||
|
// handler stamps created_by/on + updated_by/on explicitly on every write (see
|
||||||
|
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
OK: 'ok',
|
||||||
|
ERROR: 'error',
|
||||||
|
RUNNING: 'running',
|
||||||
|
};
|
||||||
|
|
||||||
|
class PluginInstance extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// A registered plugin type slug (matches a manifest `type`). Validated
|
||||||
|
// against the registry before a row is created.
|
||||||
|
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
|
||||||
|
// create time so the scheduler can dispatch without re-reading the registry
|
||||||
|
// on every run (and so a later type removal still shows what the instance was).
|
||||||
|
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
|
||||||
|
// Human label for the instance.
|
||||||
|
name: { type: 'string', isRequired: true, min: 1, max: 120 },
|
||||||
|
// Stable handle: discovery source name + unique constraint. Lowercase
|
||||||
|
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
|
||||||
|
enabled: { type: 'boolean', default: true },
|
||||||
|
// Cron schedule (5-field). The scheduler turns this into a BullMQ
|
||||||
|
// repeatable JobScheduler.
|
||||||
|
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
|
||||||
|
// Non-secret configSchema field values. Secret fields are NOT here.
|
||||||
|
config: { type: 'json', default: {} },
|
||||||
|
// Last-run bookkeeping, updated by the scheduler worker.
|
||||||
|
lastRunAt: { type: 'integer' },
|
||||||
|
lastStatus: { type: 'string' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
lastLog: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All instances the scheduler should run: enabled only. Loaded fresh each
|
||||||
|
// boot / load; not cached on the model (the scheduler is the source of truth
|
||||||
|
// for what's actually scheduled).
|
||||||
|
static async listEnabled() {
|
||||||
|
return this.list({ where: { enabled: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug — used by tests + the reconciler when only a slug is known.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginInstance, STATUS };
|
||||||
+66
-31
@@ -96,11 +96,47 @@ class Resource extends Model {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxUpdated = 0;
|
||||||
resObjs.forEach(r => {
|
resObjs.forEach(r => {
|
||||||
r.metadata.isProduction = checkProd(r.id);
|
r.metadata.isProduction = checkProd(r.id);
|
||||||
|
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { resources: resObjs, edges };
|
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||||
|
// otherwise the nearest ancestor's. A service usually carries no address of
|
||||||
|
// its own -- it is reached at the host it runs on -- so "how do I reach this"
|
||||||
|
// is only answerable from the graph, never from the row alone. Every caller
|
||||||
|
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
|
||||||
|
// must go through here, or services come back unreachable.
|
||||||
|
static async withResolvedAddress(resources) {
|
||||||
|
if (!resources || !resources.length) return [];
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
|
||||||
|
const resolve = (resId, visited = new Set()) => {
|
||||||
|
if (visited.has(resId)) return null; // prevent cycles
|
||||||
|
visited.add(resId);
|
||||||
|
|
||||||
|
const res = graph.resources.find(r => r.id === resId);
|
||||||
|
if (!res) return null;
|
||||||
|
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||||
|
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||||
|
|
||||||
|
for (const edge of graph.edges.filter(e => e.childId === resId)) {
|
||||||
|
const found = resolve(edge.parentId, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return resources.map(r => {
|
||||||
|
const data = r.toJSON ? r.toJSON() : { ...r };
|
||||||
|
data.metadata = data.metadata || {};
|
||||||
|
data.resolvedAddress = resolve(data.id);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getMyAccess(userDn) {
|
static async getMyAccess(userDn) {
|
||||||
@@ -110,38 +146,11 @@ class Resource extends Model {
|
|||||||
const resourceGroups = await ResourceGroup.list({
|
const resourceGroups = await ResourceGroup.list({
|
||||||
where: { groupCn: { in: userGroups } }
|
where: { groupCn: { in: userGroups } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||||
if (resourceIds.length === 0) return [];
|
if (resourceIds.length === 0) return [];
|
||||||
|
|
||||||
const resources = await this.list({ where: { id: { in: resourceIds } } });
|
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||||
|
|
||||||
// Resolve inherited addresses from the graph
|
|
||||||
const graph = await this.getGraph();
|
|
||||||
|
|
||||||
function resolveHost(resId, visited = new Set()) {
|
|
||||||
if (visited.has(resId)) return null; // prevent cycles
|
|
||||||
visited.add(resId);
|
|
||||||
|
|
||||||
const res = graph.resources.find(r => r.id === resId);
|
|
||||||
if (!res) return null;
|
|
||||||
if (res.metadata && res.metadata.address) return res.metadata.address;
|
|
||||||
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
|
||||||
|
|
||||||
const parentEdges = graph.edges.filter(e => e.childId === resId);
|
|
||||||
for (const edge of parentEdges) {
|
|
||||||
const found = resolveHost(edge.parentId, visited);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources.map(r => {
|
|
||||||
const data = { ...r };
|
|
||||||
data.metadata = data.metadata || {};
|
|
||||||
data.resolvedAddress = resolveHost(r.id);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fields = {
|
static fields = {
|
||||||
@@ -152,10 +161,36 @@ class Resource extends Model {
|
|||||||
owner: { type: 'string' },
|
owner: { type: 'string' },
|
||||||
description: { type: 'text' },
|
description: { type: 'text' },
|
||||||
metadata: { type: 'json', default: {} },
|
metadata: { type: 'json', default: {} },
|
||||||
|
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||||
|
// are set explicitly by the route handler on every create/update (see
|
||||||
|
// routes/api_directory_admin.js). Existing rows predating this change
|
||||||
|
// simply read back undefined -- callers must render a fallback.
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||||
|
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||||
|
// top-level resource with no site parent keeps its unprefixed group name).
|
||||||
|
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||||
|
if (visited.has(resourceId)) return null;
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId);
|
||||||
|
if (!parent) continue;
|
||||||
|
if (parent.kind === 'site') return parent.slug;
|
||||||
|
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResourceEdge extends Model {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ function toE164Digits(number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function send(to, message) {
|
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({
|
const params = new URLSearchParams({
|
||||||
api_username: conf.username,
|
api_username: conf.username,
|
||||||
api_password: conf.password,
|
api_password: conf.password,
|
||||||
|
|||||||
+48
-33
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
|||||||
const {Group} = require('./group_ldap');
|
const {Group} = require('./group_ldap');
|
||||||
const {UserVerification} = require('./verification');
|
const {UserVerification} = require('./verification');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
||||||
|
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
||||||
|
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
||||||
|
// tlsOptions, which the shared client forwards as undefined — identical to the
|
||||||
|
// previous `new Client({ url: conf.url })`.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
function hashPasswordSSHA512(password) {
|
||||||
const salt = crypto.randomBytes(8);
|
const salt = crypto.randomBytes(8);
|
||||||
@@ -23,40 +31,11 @@ const cache = new LRUCache({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to escape LDAP filter values (crucial for security)
|
|
||||||
function escapeLDAPSearchValue(val) {
|
|
||||||
return val.replace(/\\/g, '\\5c')
|
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514).
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the next available uid/gidNumber: the highest existing value below
|
// Compute the next available uid/gidNumber: the highest existing value below
|
||||||
@@ -316,6 +295,9 @@ User.listDetail = async function(){
|
|||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||||
|
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||||
|
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -494,6 +476,19 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(data.sshPublicKey){
|
if(data.sshPublicKey){
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
@@ -778,7 +773,7 @@ User.setActive = async function(active) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
|
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -793,7 +788,7 @@ User.setActive = async function(active) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||||
this.isActive = active ? 'active' : '';
|
this.isActive = active ? 'active' : '';
|
||||||
this.isInactive = active ? '' : 'inactive';
|
this.isInactive = active ? '' : 'inactive';
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -805,6 +800,19 @@ User.addSSHkey = async function(data) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(user.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(user.dn, [
|
await client.modify(user.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'add',
|
operation: 'add',
|
||||||
@@ -899,6 +907,13 @@ User.login = async function(data){
|
|||||||
}
|
}
|
||||||
let user = await this.get(data.uid || data.username);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
|
if (user.pwdAccountLockedTime) {
|
||||||
|
let error = new Error('Invalid Credentials, login failed.');
|
||||||
|
error.name = 'LDAPLoginFailed';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
try {
|
try {
|
||||||
await loginClient.bind(user.dn, data.password);
|
await loginClient.bind(user.dn, data.password);
|
||||||
|
|||||||
@@ -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
+560
-16
@@ -1,37 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.19.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.19.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -646,6 +657,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1094,6 +1111,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1242,6 +1337,30 @@
|
|||||||
"@redis/client": "^6.1.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/app-stack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/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": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1254,6 +1373,36 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/directory-schema": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-hTXxHl7Jz5IbIAYmn8dv9f0B50ocjEg5ju+UV8ZQSaBjJYpetOVfcFvT6v9xwMVtjXSYMDwKPvD8YgKOBz7xJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapts": "^8.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/orm": {
|
"node_modules/@simpleworkjs/orm": {
|
||||||
"version": "0.2.8",
|
"version": "0.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
||||||
@@ -1377,6 +1526,15 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-proxy": {
|
||||||
|
"version": "1.17.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||||
|
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2198,7 +2356,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -2288,6 +2445,54 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-ri/ugcNf4G/knwnMd2LVuwIdyzI9A2a2CipYvvfG6H4I1X23DhNrDtd8yuj46dqeE8kdoUSPlTJ9rEtfs4W/cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "5.6.1",
|
||||||
|
"msgpackr": "2.0.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bullmq-otel": ">=2.0.0",
|
||||||
|
"ioredis": ">=5.0.0",
|
||||||
|
"pg": ">=8.0.0",
|
||||||
|
"redis": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bullmq-otel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -2713,6 +2918,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2802,6 +3019,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -3155,6 +3381,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -3405,7 +3637,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -3472,6 +3703,26 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -3835,6 +4086,44 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
|
"follow-redirects": "^1.0.0",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-proxy": "^1.17.8",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"is-glob": "^4.0.1",
|
||||||
|
"is-plain-obj": "^3.0.0",
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/express": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -3951,6 +4240,59 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "2.0.0",
|
||||||
|
"cluster-key-slot": "1.1.1",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"denque": "2.1.0",
|
||||||
|
"redis-errors": "1.2.0",
|
||||||
|
"standard-as-callback": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
@@ -3993,7 +4335,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4023,7 +4364,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -4036,12 +4376,23 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -4817,9 +5168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jquery": {
|
"node_modules/jquery": {
|
||||||
"version": "3.7.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
@@ -5036,6 +5387,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@@ -5134,6 +5494,31 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||||
@@ -5278,6 +5663,37 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -5349,6 +5765,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||||
@@ -5358,6 +5780,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp": {
|
"node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||||
@@ -5394,6 +5836,21 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"node-gyp-build-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp/node_modules/isexe": {
|
"node_modules/node-gyp/node_modules/isexe": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||||
@@ -5440,6 +5897,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-nmap": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-nmap/-/node-nmap-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-VJGebpYsfqmUm46+Fq0qp1Y9VXGXZ7/WL03tHGy1oJHHxaJ2DvYLMjuYWYHDV0pgUL+e5/9rCN/QEsx3+fU9TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queued-up": "^2.0.2",
|
||||||
|
"xml2js": "^0.4.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
@@ -6031,6 +6498,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queued-up": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -6155,6 +6628,15 @@
|
|||||||
"node": ">= 20.0.0"
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -6165,6 +6647,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-cwd": {
|
"node_modules/resolve-cwd": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||||
@@ -6259,6 +6747,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -6869,6 +7366,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -7296,7 +7799,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -7330,13 +7832,17 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
@@ -7567,6 +8073,22 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7728,6 +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": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
+14
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.2",
|
"version": "1.19.4",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -23,27 +23,38 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"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,98 @@
|
|||||||
|
const nmap = require('node-nmap');
|
||||||
|
nmap.nmapLocation = "nmap"; // default
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
|
||||||
|
// not secret (it's a network range to scan), so it lives in the DB row, not
|
||||||
|
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
|
||||||
|
// the range parses — running a real scan is what `run` does.
|
||||||
|
type: 'nmap',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Nmap Network Scan',
|
||||||
|
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
|
||||||
|
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
|
||||||
|
// only sanity-check shape here — reject anything with shell metacharacters
|
||||||
|
// or whitespace, since node-nmap passes this straight to the nmap binary.
|
||||||
|
if (/\s|[;|&$`<>]/.test(targetRange)) {
|
||||||
|
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||||
|
const scan = new nmap.NmapScan(targetRange);
|
||||||
|
scan.command.push('-Pn');
|
||||||
|
scan.command.push('-F'); // fast scan, 100 top ports
|
||||||
|
scan.command.push('--min-rate', '100'); // speed up the scan
|
||||||
|
|
||||||
|
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
|
||||||
|
|
||||||
|
scan.on('complete', function(data) {
|
||||||
|
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (const host of data) {
|
||||||
|
if (!host.ip) continue;
|
||||||
|
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
|
||||||
|
const hostSlug = `nmap-host-${hostId}`;
|
||||||
|
|
||||||
|
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: host.hostname || host.ip,
|
||||||
|
slug: hostSlug,
|
||||||
|
metadata: { interfaces, os: host.osNmap }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (host.openPorts && host.openPorts.length > 0) {
|
||||||
|
for (const port of host.openPorts) {
|
||||||
|
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'service',
|
||||||
|
name: `${port.service} on ${port.port}`,
|
||||||
|
slug: svcSlug,
|
||||||
|
metadata: { port: port.port, protocol: port.protocol }
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: svcSlug, relation: 'exposes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({ resources, edges });
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.on('error', function(error) {
|
||||||
|
// node-nmap's spawn-missing-binary message ("NMAP not found at command
|
||||||
|
// location: nmap") is opaque to an admin reading lastError. Translate
|
||||||
|
// it into something actionable. (The Dockerfile installs nmap in the
|
||||||
|
// app image; this only fires if someone runs outside the container or
|
||||||
|
// strips the package.)
|
||||||
|
var msg = (error && error.message) || String(error);
|
||||||
|
if (/nmap.*not found|command location/i.test(msg)) {
|
||||||
|
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.startScan();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||||
|
// this references module.exports rather than `this`.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// Custom agent to bypass self-signed certs typical in Proxmox
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
|
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||||
|
// API token to confirm the URL + token are valid before scheduling runs.
|
||||||
|
validate: async (config) => {
|
||||||
|
const { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
|
||||||
|
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
let { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
|
throw new Error("Missing Proxmox config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure URL has no trailing slash
|
||||||
|
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 1. Get Nodes
|
||||||
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
|
if(!resNodes.ok) {
|
||||||
|
const errText = await resNodes.text();
|
||||||
|
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
|
||||||
|
}
|
||||||
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.status !== 'online') continue;
|
||||||
|
|
||||||
|
const nodeSlug = `pve-node-${node.node}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: node.node,
|
||||||
|
slug: nodeSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'hypervisor',
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
interfaces: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get VMs for this node
|
||||||
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
|
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const vm of vms) {
|
||||||
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
|
let ips = [];
|
||||||
|
let macs = [];
|
||||||
|
|
||||||
|
// Enrich from QEMU guest agent if running
|
||||||
|
if (vm.status === 'running') {
|
||||||
|
try {
|
||||||
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
|
if (agentRes.ok) {
|
||||||
|
const agentData = (await agentRes.json()).data;
|
||||||
|
if (agentData && agentData.result) {
|
||||||
|
for (const iface of agentData.result) {
|
||||||
|
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
|
||||||
|
if (iface['ip-addresses']) {
|
||||||
|
for (const ip of iface['ip-addresses']) {
|
||||||
|
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
|
||||||
|
ips.push(ip['ip-address']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich from VM config to at least get MAC if agent failed/stopped
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (confData[`net${i}`]) {
|
||||||
|
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
|
||||||
|
if(m) macs.push(m[1].toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: vm.name || `VM ${vm.vmid}`,
|
||||||
|
slug: vmSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
|
vmid: vm.vmid,
|
||||||
|
isProduction: vm.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
ip: ips[0] || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get LXCs for this node
|
||||||
|
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
|
||||||
|
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const lxc of lxcs) {
|
||||||
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
|
let ips = [];
|
||||||
|
let macs = [];
|
||||||
|
|
||||||
|
// Enrich from LXC config
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (confData[`net${i}`]) {
|
||||||
|
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
|
||||||
|
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
|
||||||
|
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
|
||||||
|
if(ipMatch) ips.push(ipMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: lxc.name || `LXC ${lxc.vmid}`,
|
||||||
|
slug: lxcSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
|
vmid: lxc.vmid,
|
||||||
|
isProduction: lxc.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
ip: ips[0] || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
},
|
||||||
|
|
||||||
|
// The generalized plugin contract calls `run`; the discovery plugins keep
|
||||||
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
|
// detached and called as a bare function reference.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||||
|
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||||
|
sticky element offset itself below both fixed elements via
|
||||||
|
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||||
|
viewport's true top:0. */
|
||||||
|
--sw-content-offset: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spa-shell {
|
#spa-shell {
|
||||||
|
|||||||
+3
-17
@@ -67,13 +67,6 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
|
|
||||||
app.api.delete('user/'+ args.uid, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePassword(args, callack){
|
function changePassword(args, callack){
|
||||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
|||||||
return m ? m[1] : dn;
|
return m ? m[1] : dn;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive, dnToUid};
|
return {list, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -306,13 +299,6 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete OAuth client "' + args.client_id + '"?')) return false;
|
|
||||||
app.api.delete('oauth/client/' + args.client_id, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(args, callack){
|
function update(args, callack){
|
||||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -325,7 +311,7 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, update, rotateSecret };
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.tos = (function(app){
|
app.tos = (function(app){
|
||||||
@@ -396,7 +382,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-170
@@ -1,3 +1,12 @@
|
|||||||
|
// Shared client framework for the theta42 apps.
|
||||||
|
//
|
||||||
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||||
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||||
|
// the /api/user/me response), never from edits to this file. Edit all three
|
||||||
|
// copies together.
|
||||||
|
//
|
||||||
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||||
|
|
||||||
var app = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// with the parsed body and rejects with the error body. get/options return
|
||||||
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||||
|
|
||||||
|
function body(method, url, data, callback){
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
@@ -104,40 +121,27 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, callback);
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT', url: baseURL+url,
|
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: 'application/json; charset=utf-8',
|
|
||||||
dataType: 'json',
|
|
||||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return $.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
url: baseURL+url,
|
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
complete: function(res, text){
|
|
||||||
callback(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(app){
|
app.auth = (function(app){
|
||||||
var user = {};
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||||
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||||
|
// this same promise instead of re-fetching.
|
||||||
|
var userPromise = null;
|
||||||
|
|
||||||
function setToken(token){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cached current user, or false when there's no token at all. Callers that
|
||||||
|
// need a fresh copy (after a login or a profile change) pass force.
|
||||||
|
function loadUser(force){
|
||||||
|
if(force || !userPromise){
|
||||||
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||||
|
userPromise = userPromise.then(function(user){
|
||||||
|
app.auth.user = app.auth.perms = user || null;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||||
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||||
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||||
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||||
|
function groupCNs(user){
|
||||||
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||||
|
if(!Array.isArray(raw)) raw = [raw];
|
||||||
|
var names = raw.map(function(group){
|
||||||
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
async function memberOf(groupNameToFind, user){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||||
|
// an optional node-style callback with the same result.
|
||||||
|
function isLoggedIn(callback){
|
||||||
|
var promise = loadUser().then(function(user){
|
||||||
|
return user || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(typeof callback === 'function'){
|
||||||
|
promise.then(function(user){
|
||||||
|
callback(null, user);
|
||||||
|
}, function(error){
|
||||||
|
callback(error, false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logIn(args, callback){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
callback(error, !!data.token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears the session only — the caller decides where to go next (the nav's
|
||||||
|
// Log Out button uses ui.logoutRedirect).
|
||||||
function logOut(callback){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
app.auth.user = app.auth.perms = null;
|
||||||
|
if(typeof callback === 'function') callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
|
// rather than by pausing document ready.
|
||||||
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
async function forceLogin(requiredGroups){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.messages.action(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
throw new Error("User does not have permission");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.holdReady(false);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
app.auth.asyncUser = app.auth.getUser();
|
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
app.user = (function(app){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
|
app.permission = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('permission/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
app.util = (function(app){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
if(!message.includes('<button')) message += `
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -415,8 +538,11 @@ app.util = (function(app){
|
|||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
console.log(name, value)
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
){
|
){
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -458,29 +584,64 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll a just-added/-edited element into view and flash its
|
||||||
|
// background, so the user's eye lands on the row that changed instead of
|
||||||
|
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||||
|
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||||
|
function revealItem(el){
|
||||||
|
var node = el && el.jquery ? el[0] : el;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node.scrollIntoView === 'function') {
|
||||||
|
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
var prevTransition = node.style.transition;
|
||||||
|
var prevBg = node.style.backgroundColor;
|
||||||
|
node.style.transition = 'background-color 1.5s ease';
|
||||||
|
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||||
|
setTimeout(function(){
|
||||||
|
node.style.backgroundColor = prevBg;
|
||||||
|
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
$('div.row').fadeIn('slow'); //show the page
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
@@ -502,9 +663,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -535,20 +696,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -556,7 +714,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
@@ -567,4 +725,3 @@ function formAJAX(btn){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
( function( $ ) {
|
|
||||||
var settings = {
|
|
||||||
rule: {
|
|
||||||
eq: function(value, options){
|
|
||||||
var compare = $('[name=' + options + ']').val();
|
|
||||||
|
|
||||||
if ( value != compare ) {
|
|
||||||
return "Miss-match";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validate = function(event) {
|
|
||||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
if(this.is('[validate]')) return this.validateField(event);
|
|
||||||
|
|
||||||
if(!this.attr('isValid')){
|
|
||||||
console.log('adding reset event')
|
|
||||||
this.on('reset', function(){
|
|
||||||
$(this).attr('isValid', false);
|
|
||||||
$(this).validateClear();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.find('[validate]').each(function(){
|
|
||||||
if(!$(this).validateField()) hasErrors = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.attr('isValid', !hasErrors);
|
|
||||||
|
|
||||||
if(hasErrors && event) event.preventDefault();
|
|
||||||
|
|
||||||
return !hasErrors;
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validateClear = function(){
|
|
||||||
$(this).find('input').each(function(){
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
$(this).removeClass('is-valid');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateField = function(){
|
|
||||||
var attr = this.attr('validate').split(':'); //array of params
|
|
||||||
var rule = attr[0];
|
|
||||||
var options = attr[1];
|
|
||||||
var value = this.val(); //link to input value
|
|
||||||
var message;
|
|
||||||
|
|
||||||
if(this.prop('disabled')) return true;
|
|
||||||
|
|
||||||
|
|
||||||
//checks if field is required, and length
|
|
||||||
if(!isNaN(options) && value.length < options){
|
|
||||||
message = `Must be ${options} characters`;
|
|
||||||
}
|
|
||||||
|
|
||||||
//checks if empty to stop processing
|
|
||||||
if(!isNaN(options) && value.length === 0) {
|
|
||||||
}else if(rule in settings.rule){
|
|
||||||
let message = settings.rule[rule].apply(this, [value, options]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateMessage(message)
|
|
||||||
return !message;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateMessage = function(message){
|
|
||||||
if(message && message !== true){
|
|
||||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
|
||||||
this.addClass('is-invalid');
|
|
||||||
}else{
|
|
||||||
this.removeClass('is-invalid');
|
|
||||||
this.addClass('is-valid');
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
jQuery.extend({
|
|
||||||
validateSettings: function( settingsObj ) {
|
|
||||||
$.extend( true, settings, settingsObj );
|
|
||||||
},
|
|
||||||
|
|
||||||
validateInit: function( ettingsObj ) {
|
|
||||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
|
||||||
$( this ).validate( settingsObj, event );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}( jQuery ));
|
|
||||||
|
|
||||||
$.validateSettings({
|
|
||||||
rule:{
|
|
||||||
ip: function( value ) {
|
|
||||||
value = value.split( '.' );
|
|
||||||
|
|
||||||
if ( value.length != 4 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
host: function( value ) {
|
|
||||||
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
user: function( value ) {
|
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
password: function( value ) {
|
|
||||||
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Weak password, Try again";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests. Mounted at /api/access-requests (app.js).
|
||||||
|
//
|
||||||
|
// The loop this closes: a user browses the catalog, finds something they cannot
|
||||||
|
// reach, asks for it; the resource's owner (or a directory admin) approves; the
|
||||||
|
// approval performs the LDAP group add. LDAP stays the access-control truth --
|
||||||
|
// this router never invents a permission, it only automates the group add an
|
||||||
|
// admin would otherwise do by hand, and records who decided.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
const { AccessRequest, STATUS } = require('../models/access_request');
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { Mail } = require('../models/email');
|
||||||
|
const { groupCns } = require('../utils/user_groups');
|
||||||
|
const { envelope, projectResource } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
const DIRECTORY_ADMIN_GROUPS = ['app_sso_directory_admin', 'app_sso_admin', 'app_super_admin'];
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// May `user` decide requests against `resource`? The resource's own owner is
|
||||||
|
// the primary approver -- that is the point of Resource.owner -- with directory
|
||||||
|
// admins as the catch-all so an unowned or orphaned resource is never stuck.
|
||||||
|
async function canDecide(user, resource, callerGroups) {
|
||||||
|
if (resource && resource.owner && resource.owner === user.uid) return true;
|
||||||
|
return callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group that satisfies a request for this resource. Prefers an explicit
|
||||||
|
// choice, else the `member`-level link (the "just let me use it" group) over an
|
||||||
|
// `owner`-level one -- requesting a resource should never silently escalate to
|
||||||
|
// its admin group.
|
||||||
|
async function resolveGroupCn(resourceId, requested) {
|
||||||
|
const links = await ResourceGroup.list({ where: { resourceId } });
|
||||||
|
if (!links.length) {
|
||||||
|
throw httpError(409, 'This resource has no access group linked, so it cannot be requested.');
|
||||||
|
}
|
||||||
|
if (requested) {
|
||||||
|
const match = links.find(l => l.groupCn === requested);
|
||||||
|
if (!match) throw httpError(400, `"${requested}" is not an access group for this resource.`);
|
||||||
|
return match.groupCn;
|
||||||
|
}
|
||||||
|
const member = links.find(l => l.accessLevel === 'member');
|
||||||
|
return (member || links[0]).groupCn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort notification. A mail failure must never fail the request itself --
|
||||||
|
// the row is the source of truth and the approver can find it in the UI.
|
||||||
|
async function notify(uid, subject, message) {
|
||||||
|
try {
|
||||||
|
const user = await User.get({ uid });
|
||||||
|
if (!user || !user.mail) return;
|
||||||
|
await Mail.sendTemplate(user.mail, 'notification', {
|
||||||
|
givenName: user.givenName || uid,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`access-request: notification to ${uid} failed:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/access-requests { slug | resourceId, groupCn?, note? }
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.user.isMachine) throw httpError(403, 'Machine accounts cannot request access.');
|
||||||
|
|
||||||
|
let resource;
|
||||||
|
if (req.body.slug) {
|
||||||
|
const found = await Resource.list({ where: { slug: req.body.slug } });
|
||||||
|
resource = found[0];
|
||||||
|
} else if (req.body.resourceId) {
|
||||||
|
resource = await Resource.get(req.body.resourceId);
|
||||||
|
}
|
||||||
|
if (!resource) throw httpError(404, 'Resource not found');
|
||||||
|
|
||||||
|
const md = resource.metadata || {};
|
||||||
|
// Opt-out, not opt-in: everything in the catalog is requestable unless an
|
||||||
|
// admin has explicitly marked it otherwise.
|
||||||
|
if (md.requestable === false) {
|
||||||
|
throw httpError(409, 'This resource is not available for self-service requests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupCn = await resolveGroupCn(resource.id, req.body.groupCn);
|
||||||
|
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (callerGroups.includes(groupCn)) {
|
||||||
|
throw httpError(409, 'You already have access to this resource.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await AccessRequest.findOpen(req.user.uid, groupCn);
|
||||||
|
if (existing) throw httpError(409, 'You already have a pending request for this resource.');
|
||||||
|
|
||||||
|
const request = await AccessRequest.create({
|
||||||
|
uid: req.user.uid,
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn,
|
||||||
|
status: STATUS.PENDING,
|
||||||
|
note: req.body.note || '',
|
||||||
|
requestedOn: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resource.owner) {
|
||||||
|
await notify(
|
||||||
|
resource.owner,
|
||||||
|
`Access request: ${resource.name}`,
|
||||||
|
`<p><strong>${req.user.uid}</strong> has requested access to <strong>${resource.name}</strong> (group <code>${groupCn}</code>).</p>` +
|
||||||
|
(req.body.note ? `<p>Their note: ${req.body.note}</p>` : '') +
|
||||||
|
`<p>Review it on the Directory page.</p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(envelope(request));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/access-requests/mine — the caller's own request history.
|
||||||
|
router.get('/mine', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await AccessRequest.listForUser(req.user.uid);
|
||||||
|
res.json(envelope(await decorate(rows)));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/access-requests — pending requests the caller may decide.
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
const isAdmin = callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||||
|
const pending = await AccessRequest.listPending();
|
||||||
|
|
||||||
|
let visible = pending;
|
||||||
|
if (!isAdmin) {
|
||||||
|
// A plain resource owner sees only requests against resources they own.
|
||||||
|
const owned = await Resource.list({ where: { owner: req.user.uid } });
|
||||||
|
const ownedIds = new Set(owned.map(r => r.id));
|
||||||
|
visible = pending.filter(r => ownedIds.has(r.resourceId));
|
||||||
|
}
|
||||||
|
res.json(envelope(await decorate(visible)));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the resource name/slug each row refers to. The UI needs it on every
|
||||||
|
// list and would otherwise issue one lookup per row.
|
||||||
|
async function decorate(rows) {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const resources = await Resource.list();
|
||||||
|
const byId = new Map(resources.map(r => [r.id, r]));
|
||||||
|
return rows.map(row => {
|
||||||
|
const data = row.toJSON ? row.toJSON() : { ...row };
|
||||||
|
const resource = byId.get(data.resourceId);
|
||||||
|
data.resource = resource
|
||||||
|
? { id: resource.id, name: resource.name, slug: resource.slug, kind: resource.kind }
|
||||||
|
: null;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/access-requests/:id/approve { decisionNote? }
|
||||||
|
router.post('/:id/approve', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await Resource.get(request.resourceId);
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||||
|
throw httpError(403, 'You do not have permission to decide this request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The LDAP write happens FIRST and is allowed to throw. Marking a request
|
||||||
|
// approved without the group add would show the user a grant they do not
|
||||||
|
// actually have -- a pending row is recoverable, a lying one is not.
|
||||||
|
const group = await Group.get(request.groupCn);
|
||||||
|
const user = await User.get({ uid: request.uid });
|
||||||
|
try {
|
||||||
|
await group.addMember(user);
|
||||||
|
} catch (err) {
|
||||||
|
// "already a member" is the goal state, not a failure. This happens
|
||||||
|
// routinely: groupOfNames requires at least one member, so creating a
|
||||||
|
// resource seeds its auto-created groups with the creator's DN, and an
|
||||||
|
// admin may also grant access by hand while a request sits pending.
|
||||||
|
// Without this the request would 500 and stay pending forever.
|
||||||
|
const alreadyMember = err.name === 'TypeOrValueExistsError' || err.code === 20;
|
||||||
|
if (!alreadyMember) throw err;
|
||||||
|
}
|
||||||
|
User.clearCache(); // membership feeds cached isAdmin / group-gated nav
|
||||||
|
|
||||||
|
const updated = await request.update({
|
||||||
|
status: STATUS.APPROVED,
|
||||||
|
decidedBy: req.user.uid,
|
||||||
|
decidedOn: Date.now(),
|
||||||
|
decisionNote: req.body.decisionNote || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notify(
|
||||||
|
request.uid,
|
||||||
|
`Access approved: ${resource ? resource.name : request.groupCn}`,
|
||||||
|
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was approved by ${req.user.uid}.</p>` +
|
||||||
|
`<p>You may need to sign out and back in for the change to take effect everywhere.</p>`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/access-requests/:id/deny { decisionNote? }
|
||||||
|
router.post('/:id/deny', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await Resource.get(request.resourceId);
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||||
|
throw httpError(403, 'You do not have permission to decide this request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await request.update({
|
||||||
|
status: STATUS.DENIED,
|
||||||
|
decidedBy: req.user.uid,
|
||||||
|
decidedOn: Date.now(),
|
||||||
|
decisionNote: req.body.decisionNote || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notify(
|
||||||
|
request.uid,
|
||||||
|
`Access request declined: ${resource ? resource.name : request.groupCn}`,
|
||||||
|
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was declined.</p>` +
|
||||||
|
(req.body.decisionNote ? `<p>Reason: ${req.body.decisionNote}</p>` : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/access-requests/:id — requester withdraws their own pending request.
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.uid !== req.user.uid) {
|
||||||
|
throw httpError(403, 'You can only withdraw your own requests.');
|
||||||
|
}
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
const updated = await request.update({ status: STATUS.CANCELLED, decidedOn: Date.now() });
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,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,6 +3,31 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
const { Group } = require('../models/group_ldap');
|
const { Group } = require('../models/group_ldap');
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { cnFromDn } = require('../utils/user_groups');
|
||||||
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
|
||||||
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
|
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
||||||
|
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||||
|
// caller's real work.
|
||||||
|
async function nestGroup(childCn, parentCn) {
|
||||||
|
try {
|
||||||
|
const parent = await Group.get(parentCn);
|
||||||
|
const child = await Group.get(childCn);
|
||||||
|
if (await Group.wouldCycle(parentCn, child.dn)) {
|
||||||
|
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await parent.addMember({ dn: child.dn });
|
||||||
|
} catch (err) {
|
||||||
|
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
|
||||||
|
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -17,8 +42,15 @@ router.use(async (req, res, next) => {
|
|||||||
// --- Resources ---
|
// --- Resources ---
|
||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
res.json({ results: resources });
|
resources = resources.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
return !isAuto || isManaged;
|
||||||
|
});
|
||||||
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,25 +72,35 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.body.owner = req.body.owner || req.user.uid;
|
req.body.owner = req.body.owner || req.user.uid;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
req.body.created_by = req.body.created_by || req.user.uid;
|
||||||
|
req.body.created_on = now;
|
||||||
|
req.body.updated_by = req.user.uid;
|
||||||
|
req.body.updated_on = now;
|
||||||
|
|
||||||
let r;
|
let r;
|
||||||
if (req.body.kind === 'oauth') {
|
if (req.body.kind === 'oauth') {
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
// Pass created_by explicitly for the wrapper
|
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||||
|
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||||
req.body.created_by = req.body.owner;
|
req.body.created_by = req.body.owner;
|
||||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
r = await OAuthClient.add(req.body);
|
r = await OAuthClient.add(req.body);
|
||||||
} else {
|
} else {
|
||||||
r = await Resource.create(req.body);
|
r = await Resource.create(req.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
if (r.kind === 'host' || r.kind === 'service') {
|
||||||
|
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||||
|
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
||||||
|
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
const createGroup = async (suffix, accessLevel) => {
|
||||||
const cn = `${r.slug}_${suffix}`;
|
const cn = groupCn(suffix);
|
||||||
try {
|
try {
|
||||||
await Group.add({
|
await Group.add({
|
||||||
name: cn,
|
name: cn,
|
||||||
@@ -76,6 +118,21 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
await createGroup('access', 'member');
|
await createGroup('access', 'member');
|
||||||
await createGroup('admin', 'owner');
|
await createGroup('admin', 'owner');
|
||||||
|
|
||||||
|
// Wire up the two standing relationships every resource has, as nesting
|
||||||
|
// rather than as membership that has to be maintained per resource:
|
||||||
|
//
|
||||||
|
// app_super_admin -> <slug>_admin cross-app super admins administer
|
||||||
|
// every resource, automatically
|
||||||
|
// <slug>_admin -> <slug>_access administering something implies
|
||||||
|
// being able to use it
|
||||||
|
//
|
||||||
|
// Before nesting, both of these could only be expressed by adding every
|
||||||
|
// super admin to every new group by hand -- which nobody does, so the
|
||||||
|
// groups drifted. A failure here must not fail resource creation: the
|
||||||
|
// resource and its groups already exist and the nesting is repairable.
|
||||||
|
await nestGroup(groupCn('admin'), groupCn('access'));
|
||||||
|
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
@@ -92,15 +149,8 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
|
|
||||||
router.put('/resources/:id', async (req, res, next) => {
|
router.put('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let r;
|
// Validate before loading anything -- a rejected body should never have
|
||||||
if (req.body.kind === 'oauth') {
|
// touched the store.
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
|
||||||
r = await OAuthClient.get(req.params.id);
|
|
||||||
} else {
|
|
||||||
r = await Resource.get(req.params.id);
|
|
||||||
}
|
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
|
||||||
|
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
@@ -110,14 +160,20 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated;
|
// OAuthClient is a wrapper over the same `resource` row, but its .update()
|
||||||
if (req.body.kind === 'oauth') {
|
// handles the oauth-specific body fields (redirect_uris, scopes,
|
||||||
updated = await r.update(req.body);
|
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
|
||||||
} else {
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
updated = await r.update(req.body);
|
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
|
||||||
}
|
const r = await model.get(req.params.id);
|
||||||
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
req.body.updated_by = req.user.uid;
|
||||||
|
req.body.updated_on = Date.now();
|
||||||
|
|
||||||
|
const updated = await r.update(req.body);
|
||||||
|
|
||||||
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||||
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||||
for (const e of existingEdges) {
|
for (const e of existingEdges) {
|
||||||
@@ -145,17 +201,32 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/service-token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { ServiceToken } = require('../models/token');
|
||||||
|
const token = await ServiceToken.issue(req.params.id, req.user.uid);
|
||||||
|
res.json({ results: { token: token.token } });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/resources/:id', async (req, res, next) => {
|
router.delete('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await Resource.get(req.params.id);
|
const r = await Resource.get(req.params.id);
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
await r.delete();
|
// Clear the dependents FIRST. There is no transaction here, so ordering is
|
||||||
// Also delete edges and groups involving this resource
|
// the only thing protecting us: if a dependent delete throws after the
|
||||||
|
// resource row is gone, the leftovers are edges/links pointing at a
|
||||||
|
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
|
||||||
|
// with the resource still present is the recoverable direction (retry the
|
||||||
|
// delete); the caller sees the error either way.
|
||||||
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||||
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||||
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||||
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||||
for (const g of groups) await g.delete();
|
for (const g of groups) await g.delete();
|
||||||
|
await r.delete();
|
||||||
res.json({ results: true });
|
res.json({ results: true });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -208,19 +279,138 @@ router.delete('/groups/:id', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Access visibility ---
|
||||||
|
//
|
||||||
|
// The two questions an access-control pane has to answer, neither of which the
|
||||||
|
// directory could answer before: "who can reach this resource" (a column on the
|
||||||
|
// table, rather than three clicks into a modal) and "what can this user reach"
|
||||||
|
// (which had no UI at all). Both are joins of the same two sets, so both are
|
||||||
|
// served from one cached Group.listDetail() rather than a lookup per row.
|
||||||
|
|
||||||
|
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
|
||||||
|
async function dnToUidMap() {
|
||||||
|
const users = await User.listDetail();
|
||||||
|
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
|
||||||
|
router.get('/access-summary', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [links, groups, uidByDn] = await Promise.all([
|
||||||
|
ResourceGroup.list(),
|
||||||
|
Group.listDetail(),
|
||||||
|
dnToUidMap(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupByCn = new Map(groups.map(g => [g.cn, g]));
|
||||||
|
const summary = {};
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const group = groupByCn.get(link.groupCn);
|
||||||
|
// A link whose LDAP group has been deleted out from under it: report it
|
||||||
|
// rather than skipping, since a dangling link grants nothing and the
|
||||||
|
// admin needs to see that it is dead.
|
||||||
|
//
|
||||||
|
// Counts come from the transitive closure, not from `member`. Reading the
|
||||||
|
// attribute would report only who is listed on the group, missing anyone
|
||||||
|
// who reaches it through a nested group -- and since app_super_admin is
|
||||||
|
// nested into every resource's _admin group, that is not an edge case.
|
||||||
|
let members = [];
|
||||||
|
if (group) {
|
||||||
|
const eff = await Group.effectiveMembers(link.groupCn);
|
||||||
|
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
|
||||||
|
entry.groups.push({
|
||||||
|
cn: link.groupCn,
|
||||||
|
accessLevel: link.accessLevel,
|
||||||
|
exists: !!group,
|
||||||
|
memberCount: members.length,
|
||||||
|
});
|
||||||
|
for (const uid of members) {
|
||||||
|
if (!entry.members.includes(uid)) entry.members.push(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(summary)) {
|
||||||
|
summary[id].memberCount = summary[id].members.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: summary });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /user-access/:uid — every resource a given user can reach, and via which
|
||||||
|
// group. This is the reverse lookup; previously an admin could only see their
|
||||||
|
// own access, via /api/discovery/me.
|
||||||
|
router.get('/user-access/:uid', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await User.get({ uid: req.params.uid });
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const dn = String(user.dn).toLowerCase();
|
||||||
|
const groups = await Group.listDetail();
|
||||||
|
const memberOf = groups
|
||||||
|
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
|
||||||
|
.map(g => g.cn);
|
||||||
|
|
||||||
|
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
|
||||||
|
const byId = new Map(resources.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const link of links) {
|
||||||
|
if (!memberOf.includes(link.groupCn)) continue;
|
||||||
|
const resource = byId.get(link.resourceId);
|
||||||
|
if (!resource) continue;
|
||||||
|
results.push({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
slug: resource.slug,
|
||||||
|
kind: resource.kind,
|
||||||
|
groupCn: link.groupCn,
|
||||||
|
accessLevel: link.accessLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tail the last `lines` lines of a log file without shelling out. Reads at most
|
||||||
|
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
|
||||||
|
// heap. A missing/unreadable file is normal (the log only exists once slapd has
|
||||||
|
// written to it), so it yields '' rather than an error.
|
||||||
|
const MAX_TAIL_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
async function tailFile(filePath, lines = 100) {
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
fh = await fs.open(filePath, 'r');
|
||||||
|
const { size } = await fh.stat();
|
||||||
|
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
||||||
|
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
|
||||||
|
await fh.read(buf, 0, buf.length, start);
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
// A partial first line when we started mid-file; drop it.
|
||||||
|
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
||||||
|
return rows.slice(-lines).join('\n');
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
} finally {
|
||||||
|
if (fh) await fh.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/audit-logs', async (req, res, next) => {
|
router.get('/audit-logs', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const [ldap, oauth, audit] = await Promise.all([
|
||||||
const { execSync } = require('child_process');
|
tailFile('/var/lib/ldap/slapd.log'),
|
||||||
let ldapLogs = '';
|
tailFile('/var/lib/ldap/oauth.log'),
|
||||||
let oauthLogs = '';
|
tailFile('/var/lib/ldap/auditlog.ldif'),
|
||||||
let auditLogs = '';
|
]);
|
||||||
|
res.json({ results: { ldap, oauth, audit } });
|
||||||
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
|
|
||||||
|
|
||||||
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
|
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { Resource, ResourceGroup } = require('../models/resource');
|
|
||||||
|
|
||||||
// GET /api/discovery/me
|
|
||||||
// Returns the list of resources the current user has access to.
|
|
||||||
router.get('/me', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const userGroups = req.user.groups || []; // array of LDAP group CNs
|
|
||||||
const accessibleResourceIds = new Set();
|
|
||||||
|
|
||||||
if (req.user.isMachine) {
|
|
||||||
// Machines only have access to themselves by default
|
|
||||||
accessibleResourceIds.add(req.resourceId);
|
|
||||||
} else {
|
|
||||||
// End users get access via groups
|
|
||||||
const allGroups = await ResourceGroup.list();
|
|
||||||
for (const rg of allGroups) {
|
|
||||||
if (userGroups.includes(rg.groupCn)) {
|
|
||||||
accessibleResourceIds.add(rg.resourceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all resources and filter
|
|
||||||
const allResources = await Resource.list();
|
|
||||||
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
|
|
||||||
|
|
||||||
res.json({ results: accessible });
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const metrics = require('../utils/metrics');
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
// /api/metrics/executive
|
// /api/metrics/overview
|
||||||
router.get('/executive', async (req, res, next) => {
|
router.get('/overview', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
|
||||||
|
//
|
||||||
|
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
|
||||||
|
// config via a Redis hash). Here every plugin is a PluginInstance row (see
|
||||||
|
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
|
||||||
|
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
|
||||||
|
//
|
||||||
|
// Gated router-wide to the same admin groups as the directory admin API, so
|
||||||
|
// existing directory admins keep access. Secrets are never returned in
|
||||||
|
// cleartext — only masked (`********`) — and never persisted in the DB.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||||
|
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
// Derive a stable, unique slug from an instance name when the caller didn't
|
||||||
|
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
|
||||||
|
// and prefixes `plugin-` if the result would otherwise start with a character
|
||||||
|
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
|
||||||
|
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
|
||||||
|
function slugify(name) {
|
||||||
|
let s = String(name || '').toLowerCase().trim();
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (!s) s = 'plugin';
|
||||||
|
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
|
||||||
|
return s.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeSlug(name, isTaken) {
|
||||||
|
const base = slugify(name);
|
||||||
|
if (!await isTaken(base)) return base;
|
||||||
|
for (let i = 2; i <= 16; i++) {
|
||||||
|
const cand = `${base}-${i}`.slice(0, 64);
|
||||||
|
if (!await isTaken(cand)) return cand;
|
||||||
|
}
|
||||||
|
return null; // exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||||
|
// (app_super_admin is always allowed by permission.byGroup).
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plain object for the wire, with masked secret values attached under
|
||||||
|
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
|
||||||
|
async function serialize(instance) {
|
||||||
|
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
|
||||||
|
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
|
||||||
|
obj.secrets = registry.mask(instance.pluginType, secrets);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a create/update payload against a plugin type's configSchema.
|
||||||
|
// Returns an error string or null. `flat` is the merged config + secret values
|
||||||
|
// (the UI sends one flat object; the API splits it).
|
||||||
|
function validateFields(type, flat) {
|
||||||
|
const required = registry.requiredKeys(type);
|
||||||
|
for (const key of required) {
|
||||||
|
const v = flat && flat[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
return `Missing required field: ${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plugin types (for the create-instance picker + form) ---
|
||||||
|
router.get('/types', (req, res) => {
|
||||||
|
res.json({ results: registry.getTypes() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- List instances ---
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const instances = await PluginInstance.list();
|
||||||
|
const out = [];
|
||||||
|
for (const inst of instances) out.push(await serialize(inst));
|
||||||
|
res.json({ results: out });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ results: await serialize(inst) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create instance ---
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { pluginType, name, slug, cron } = req.body;
|
||||||
|
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
||||||
|
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
// Slug is optional: derive it from the name when absent. When supplied,
|
||||||
|
// validate it (admins editing via API may still pass one explicitly).
|
||||||
|
let finalSlug = slug;
|
||||||
|
if (finalSlug) {
|
||||||
|
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
||||||
|
} else {
|
||||||
|
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
|
||||||
|
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
|
||||||
|
}
|
||||||
|
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||||
|
|
||||||
|
// `config` from the client is a flat object of all field values (secret +
|
||||||
|
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
|
||||||
|
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
|
||||||
|
const fieldErr = validateFields(pluginType, flat);
|
||||||
|
if (fieldErr) return res.status(400).json({ error: fieldErr });
|
||||||
|
|
||||||
|
const manifest = registry.getManifest(pluginType);
|
||||||
|
const { config, secrets } = registry.splitConfig(pluginType, flat);
|
||||||
|
const enabled = req.body.enabled !== false; // default true
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const instance = await PluginInstance.create({
|
||||||
|
pluginType,
|
||||||
|
category: manifest.category,
|
||||||
|
name,
|
||||||
|
slug: finalSlug,
|
||||||
|
enabled,
|
||||||
|
cron: cron || '0 * * * *',
|
||||||
|
config,
|
||||||
|
created_by: req.user.uid,
|
||||||
|
created_on: now,
|
||||||
|
updated_by: req.user.uid,
|
||||||
|
updated_on: now
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pluginSecrets.write(instance.id, secrets);
|
||||||
|
} catch (err) {
|
||||||
|
// Most likely the sso-broker policy lacks secret/plugins/* — the
|
||||||
|
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
|
||||||
|
// secret write doesn't strand a half-created instance.
|
||||||
|
await instance.delete().catch(() => {});
|
||||||
|
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await scheduleInstance(instance);
|
||||||
|
await runInstanceNow(instance.id);
|
||||||
|
}
|
||||||
|
res.json({ results: await serialize(instance) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Update instance (name/cron/enabled/non-secret config) ---
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (req.body.name !== undefined) updates.name = req.body.name;
|
||||||
|
if (req.body.cron !== undefined) {
|
||||||
|
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||||
|
updates.cron = req.body.cron;
|
||||||
|
}
|
||||||
|
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
|
||||||
|
|
||||||
|
// Non-secret config: split the client's flat config so secret fields are
|
||||||
|
// never written to the DB. Secrets are changed via PUT /:id/secrets.
|
||||||
|
if (req.body.config !== undefined && typeof req.body.config === 'object') {
|
||||||
|
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
|
||||||
|
updates.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.updated_by = req.user.uid;
|
||||||
|
updates.updated_on = Date.now();
|
||||||
|
|
||||||
|
const updated = await inst.update(updates);
|
||||||
|
|
||||||
|
// Re-schedule if the schedule-relevant fields moved.
|
||||||
|
if (updates.cron !== undefined || updates.enabled !== undefined) {
|
||||||
|
await scheduleInstance(updated);
|
||||||
|
}
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Update secrets only ---
|
||||||
|
router.put('/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||||
|
|
||||||
|
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
|
||||||
|
// values so an unchanged masked field is a no-op.
|
||||||
|
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
|
||||||
|
await pluginSecrets.write(inst.id, secrets);
|
||||||
|
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Test (validate) ---
|
||||||
|
router.post('/:id/test', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const mod = registry.getModule(inst.pluginType);
|
||||||
|
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
|
||||||
|
const cfg = await pluginSecrets.mergeForRun(inst);
|
||||||
|
const result = await mod.validate(cfg);
|
||||||
|
if (result && result.ok) return res.json(result);
|
||||||
|
return res.status(400).json(result || { ok: false, error: 'validation failed' });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Load (enable + schedule + run now) ---
|
||||||
|
router.post('/:id/load', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
await scheduleInstance(updated);
|
||||||
|
await runInstanceNow(updated.id);
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Unload (unschedule + disable) ---
|
||||||
|
router.post('/:id/unload', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await unscheduleInstance(inst.id);
|
||||||
|
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Run now (regardless of enabled) ---
|
||||||
|
router.post('/:id/run', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await runInstanceNow(inst.id);
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Last-run status ---
|
||||||
|
router.get('/:id/runs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Delete (unschedule + remove secrets + delete row) ---
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await unscheduleInstance(inst.id);
|
||||||
|
await pluginSecrets.remove(inst.id); // best-effort
|
||||||
|
await inst.delete();
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
+194
-3
@@ -1,4 +1,195 @@
|
|||||||
const autoRouter = require('./autoRouter');
|
'use strict';
|
||||||
const { Resource } = require('../models/resource');
|
|
||||||
|
|
||||||
module.exports = autoRouter(Resource);
|
// Public directory discovery API. Mounted at /api/discovery (app.js, before
|
||||||
|
// the 404 catcher). Every response uses the `{ results }` envelope and the
|
||||||
|
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
|
||||||
|
// OAuth client's client_secret_hash) never leave the server and non-admins only
|
||||||
|
// see the public metadata allowlist.
|
||||||
|
//
|
||||||
|
// This replaces the autoRouter mount (which returned bare arrays — the shape
|
||||||
|
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
||||||
|
// bridge) and absorbs the dead /me handler that used to live in
|
||||||
|
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
||||||
|
//
|
||||||
|
// Group CNs come from utils/user_groups — `req.user` has `memberOf` (DNs) and
|
||||||
|
// no `.groups`, so reading `.groups` off it directly yields [] for every human
|
||||||
|
// caller. See that file for what that silently broke.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
const { withGroups } = require('../utils/user_groups');
|
||||||
|
const {
|
||||||
|
envelope,
|
||||||
|
projectResource,
|
||||||
|
projectResources,
|
||||||
|
isDirectoryAdmin,
|
||||||
|
} = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
// Resolve the caller's groups once per request and hand back the projection
|
||||||
|
// flag. Every handler needs both, and both are wrong if taken off req.user raw.
|
||||||
|
async function callerView(req) {
|
||||||
|
const user = await withGroups(req.user);
|
||||||
|
return { user, fullMetadata: isDirectoryAdmin(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/discovery/resources[?kind=&group=&parent=]
|
||||||
|
router.get('/resources', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
|
const resources = await Resource.search(req.query);
|
||||||
|
res.json(envelope(projectResources(resources, { fullMetadata })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/resources/:slug
|
||||||
|
router.get('/resources/:slug', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
|
// parents/children are edges (no secrets); project only the resource body.
|
||||||
|
const projected = projectResource(resource, { fullMetadata });
|
||||||
|
projected.parents = resource.parents;
|
||||||
|
projected.children = resource.children;
|
||||||
|
res.json(envelope(projected));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/graph
|
||||||
|
router.get('/graph', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
|
const graph = await Resource.getGraph();
|
||||||
|
res.json(envelope({
|
||||||
|
resources: projectResources(graph.resources, { fullMetadata }),
|
||||||
|
edges: graph.edges,
|
||||||
|
updated_on: graph.updated_on
|
||||||
|
}));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/me
|
||||||
|
// Returns the resources the current caller can reach. Machines see only their
|
||||||
|
// own resource; humans get the union of their LDAP groups' resources plus
|
||||||
|
// anything flagged isPublic.
|
||||||
|
router.get('/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { user, fullMetadata } = await callerView(req);
|
||||||
|
let accessible;
|
||||||
|
if (req.user && req.user.isMachine) {
|
||||||
|
accessible = await Resource.list({ where: { id: req.resourceId } });
|
||||||
|
} else {
|
||||||
|
const ids = new Set();
|
||||||
|
if (user.groups.length) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: user.groups } } });
|
||||||
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
|
}
|
||||||
|
const all = await Resource.list();
|
||||||
|
accessible = all.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
if (isAuto && !isManaged) return false;
|
||||||
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
||||||
|
// service inherits it from its host, so it must be computed here rather
|
||||||
|
// than left to each caller to guess at address || ip.
|
||||||
|
accessible = await Resource.withResolvedAddress(accessible);
|
||||||
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/access/:uid[/:slug]
|
||||||
|
// Answers per-user access for a machine caller (e.g. jump-host).
|
||||||
|
router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
|
if (!req.user || (!req.user.isMachine && !fullMetadata)) {
|
||||||
|
return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' }));
|
||||||
|
}
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { groupCns } = require('../utils/user_groups');
|
||||||
|
|
||||||
|
const targetUser = await User.get(req.params.uid).catch(() => null);
|
||||||
|
if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' }));
|
||||||
|
|
||||||
|
const groups = await groupCns(targetUser);
|
||||||
|
const ids = new Set();
|
||||||
|
if (groups.length) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } });
|
||||||
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = await Resource.list();
|
||||||
|
if (req.params.slug) all = all.filter(r => r.slug === req.params.slug);
|
||||||
|
|
||||||
|
let accessible = all.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
if (isAuto && !isManaged) return false;
|
||||||
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
||||||
|
});
|
||||||
|
|
||||||
|
accessible = await Resource.withResolvedAddress(accessible);
|
||||||
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/sync
|
||||||
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
||||||
|
router.post('/sync', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
// Assuming the caller provides a source name and payload
|
||||||
|
const source = req.body.source || 'agent';
|
||||||
|
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
|
||||||
|
res.json(envelope({ success: true }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/promote/:slug
|
||||||
|
// Promotes an unmanaged device to managed by creating its LDAP groups.
|
||||||
|
router.post('/promote/:slug', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
|
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
|
||||||
|
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const accessGroup = `${resource.slug}_access`;
|
||||||
|
const adminGroup = `${resource.slug}_admin`;
|
||||||
|
|
||||||
|
// Create groups if they don't exist
|
||||||
|
try { await Group.get(accessGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
try { await Group.get(adminGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link them
|
||||||
|
const crypto = require('crypto');
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: accessGroup,
|
||||||
|
accessLevel: 'user'
|
||||||
|
});
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: adminGroup,
|
||||||
|
accessLevel: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = resource.metadata || {};
|
||||||
|
meta.managed = true;
|
||||||
|
await resource.update({ 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')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
|
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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
+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){
|
router.put('/owner/:group/:uid', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|
||||||
@@ -82,11 +163,25 @@ router.put('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.addMember(user);
|
||||||
|
// Group membership feeds directly into cached-User-derived state
|
||||||
|
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||||
|
// a membership change here is invisible for up to the cache's TTL.
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.addMember(user),
|
results,
|
||||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// Already a member -- surfaced as a plain 500 before, which read as a
|
||||||
|
// server fault for what is really a no-op. Common in practice because
|
||||||
|
// groupOfNames needs at least one member, so whoever creates a group is
|
||||||
|
// seeded into it and is then "added" again by the obvious next click.
|
||||||
|
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
|
||||||
|
});
|
||||||
|
}
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,8 +193,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.removeMember(user);
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.removeMember(user),
|
results,
|
||||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
+70
-19
@@ -10,34 +10,34 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
|
|||||||
const {Tos} = require('../models/tos');
|
const {Tos} = require('../models/tos');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
name: conf.name,
|
name: conf.name,
|
||||||
logo: conf.logo,
|
logo: conf.logo,
|
||||||
|
// Connection conventions the catalog needs to render "how to reach this"
|
||||||
|
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
|
||||||
|
// port are public connection info, not credentials.
|
||||||
|
directoryConf: {
|
||||||
|
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
|
||||||
|
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
|
||||||
|
},
|
||||||
...buildInfo,
|
...buildInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// List of front end node modules to be served
|
||||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
|
|
||||||
'moment', '@popper', 'jq-repeat',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Server front end modules
|
|
||||||
// https://stackoverflow.com/a/55700773/3140931
|
|
||||||
// Vendor libraries only change when package versions are bumped (a rebuild),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
||||||
// still cover that rare case with a cheap 304 instead of a stale asset.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// every deploy and isn't cache-busted/fingerprinted.
|
||||||
|
mountStaticModules(router, {
|
||||||
|
root: path.join(__dirname, '..'),
|
||||||
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
|
||||||
// local folder. Shorter maxAge than /static-modules since this is the app's
|
|
||||||
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
|
|
||||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
|
|
||||||
|
|
||||||
// Public health endpoint for container/orchestration healthchecks.
|
// Public health endpoint for container/orchestration healthchecks.
|
||||||
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
||||||
router.get('/health', function(req, res) {
|
router.get('/health', function(req, res) {
|
||||||
@@ -55,18 +55,69 @@ router.get('/tos', async function(req, res, next) {
|
|||||||
|
|
||||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
// (broadcast + history) merged into one page.
|
// (broadcast + history) merged into one page.
|
||||||
router.get('/executive', function(req, res) {
|
router.get('/overview', function(req, res) {
|
||||||
res.render('executive', {...values});
|
res.render('overview', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
|
router.get('/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) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('directory', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/discovery', function(req, res, next) {
|
||||||
|
res.redirect('/directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/plugins', function(req, res, next) {
|
||||||
|
// 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).
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
res.render('plugins', {...values, pluginTypes: registry.types });
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
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]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
const client = await OAuthClient.get(req.params.client_id);
|
||||||
await client.remove();
|
await client.delete();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
client_id: req.params.client_id,
|
client_id: req.params.client_id,
|
||||||
|
|||||||
+21
-3
@@ -4,6 +4,7 @@ const router = require('express').Router();
|
|||||||
const {User} = require('../models/user');
|
const {User} = require('../models/user');
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
|
const {groupCns} = require('../utils/user_groups');
|
||||||
const {UserVerification} = require('../models/verification');
|
const {UserVerification} = require('../models/verification');
|
||||||
const {InviteToken} = require('../models/token');
|
const {InviteToken} = require('../models/token');
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ router.post('/', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({results: user});
|
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,24 @@ router.delete('/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/me', async function(req, res, next){
|
router.get('/me', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json(await User.get({uid: req.user.uid}));
|
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
|
||||||
|
|
||||||
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
|
// membership in app_sso_admin or the cross-app app_super_admin group.
|
||||||
|
//
|
||||||
|
// Resolved via groupCns rather than read off `memberOf` directly: with
|
||||||
|
// nested groups, memberOf is only transitive when the directory carries
|
||||||
|
// the nestgroup overlay. Against a server without it, an admin who holds
|
||||||
|
// the group through nesting would get isAdmin=false here and silently
|
||||||
|
// lose the whole admin UI -- while still passing every server-side
|
||||||
|
// permission check, which resolves nesting properly. groupCns gives the
|
||||||
|
// same answer in both modes.
|
||||||
|
const groups = await groupCns(user);
|
||||||
|
user.groups = groups;
|
||||||
|
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
||||||
|
|
||||||
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -97,7 +115,7 @@ router.put('/password', async function(req, res, next){
|
|||||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||||
await verif.update({ password_must_change: false });
|
await verif.update({ password_must_change: false });
|
||||||
User.clearCache();
|
User.clearCache();
|
||||||
return res.json({results: result});
|
return res.json({results: result, message: 'Password changed.'});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// GET /api/webhooks
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hooks = await Webhook.list();
|
||||||
|
res.json({ results: hooks });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/webhooks
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, url, events, secret } = req.body;
|
||||||
|
const hook = await Webhook.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name, url, events, secret,
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
res.json({ results: hook });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/webhooks/:id
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hook = await Webhook.get(req.params.id);
|
||||||
|
if (!hook) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await hook.delete();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
|
const { WebhookEmitter } = require('./webhook_emitter');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
class DiscoveryReconciler {
|
||||||
|
static async reconcile(sourceName, payload) {
|
||||||
|
const { resources = [], edges = [] } = payload;
|
||||||
|
let newDevices = 0;
|
||||||
|
|
||||||
|
for (const res of resources) {
|
||||||
|
if (!res.metadata) res.metadata = {};
|
||||||
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
|
let existing = null;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
});
|
||||||
|
res._actualId = existing.id;
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const sources = new Set([sourceName]);
|
||||||
|
res.metadata.discovery_sources = [...sources];
|
||||||
|
res.metadata.last_seen = Date.now();
|
||||||
|
|
||||||
|
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
|
|
||||||
|
const created = await Resource.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: res.kind || 'unmanaged_device',
|
||||||
|
name: res.name || slug,
|
||||||
|
slug: slug,
|
||||||
|
metadata: res.metadata,
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
newDevices++;
|
||||||
|
res._actualId = created.id; // Map original slug to actual ID
|
||||||
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now process edges
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
const existingEdges = await ResourceEdge.list();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
|
||||||
|
let parentId = null;
|
||||||
|
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
|
||||||
|
if (parentResInPayload && parentResInPayload._actualId) {
|
||||||
|
parentId = parentResInPayload._actualId;
|
||||||
|
} else {
|
||||||
|
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
|
||||||
|
if (parentResInDb) parentId = parentResInDb.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find child ID
|
||||||
|
let childId = null;
|
||||||
|
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
|
||||||
|
if (childResInPayload && childResInPayload._actualId) {
|
||||||
|
childId = childResInPayload._actualId;
|
||||||
|
} else {
|
||||||
|
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
|
||||||
|
if (childResInDb) childId = childResInDb.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId && childId) {
|
||||||
|
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||||
|
if (!edgeExists) {
|
||||||
|
await ResourceEdge.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
parentId,
|
||||||
|
childId,
|
||||||
|
relation: edge.relation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDevices > 0) {
|
||||||
|
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async garbageCollect(staleMs = 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
const cutoff = Date.now() - staleMs;
|
||||||
|
let archived = 0;
|
||||||
|
|
||||||
|
for (const res of allRes) {
|
||||||
|
const meta = res.metadata || {};
|
||||||
|
const sources = meta.discovery_sources || [];
|
||||||
|
// Only garbage collect things that are exclusively auto-discovered
|
||||||
|
if (sources.length > 0 && !sources.includes('manual')) {
|
||||||
|
if (meta.last_seen && meta.last_seen < cutoff && meta.lifecycle_state !== 'archived') {
|
||||||
|
meta.lifecycle_state = 'archived';
|
||||||
|
await res.update({ metadata: meta, updated_on: Math.floor(Date.now() / 1000) });
|
||||||
|
archived++;
|
||||||
|
WebhookEmitter.emit('discovery.device_archived', res.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (archived > 0) console.log(`[DiscoveryReconciler] Garbage collected ${archived} stale devices.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DiscoveryReconciler };
|
||||||
@@ -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,215 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Discovery / plugin scheduler.
|
||||||
|
//
|
||||||
|
// Generalized from the one-shot discovery-plugin loader: plugin *types* live
|
||||||
|
// under nodejs/plugins/<category>/<type>.js (see services/plugin_registry.js),
|
||||||
|
// and configured, loadable/unloadable *instances* live in the PluginInstance
|
||||||
|
// table (models/plugin_instance.js). This module schedules enabled instances
|
||||||
|
// on cron via BullMQ JobSchedulers and runs them in a Worker.
|
||||||
|
//
|
||||||
|
// Each instance owns a stable JobScheduler id (`plugin:<instanceId>`) so load/
|
||||||
|
// unload can add/remove a single schedule without disturbing the others —
|
||||||
|
// `upsertJobScheduler`/`removeJobScheduler` (BullMQ v6) take that id directly.
|
||||||
|
//
|
||||||
|
// Per-instance secrets are merged in from OpenBao (utils/plugin_secrets.js) at
|
||||||
|
// run time; the plugin's run()/discover() receives the combined non-secret
|
||||||
|
// config + secret values as a single `config` object, exactly as the legacy
|
||||||
|
// static-config path did.
|
||||||
|
|
||||||
|
const { Queue, Worker } = require('bullmq');
|
||||||
|
const { DiscoveryReconciler } = require('./discovery_reconciler');
|
||||||
|
const pluginRegistry = require('./plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
// Ensure Redis connection works for BullMQ
|
||||||
|
const redisOpts = { maxRetriesPerRequest: null };
|
||||||
|
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisOpts);
|
||||||
|
|
||||||
|
const discoveryQueue = new Queue('discovery', { connection });
|
||||||
|
|
||||||
|
const RUN = 'run_plugin';
|
||||||
|
const GC = 'garbage_collect';
|
||||||
|
function pluginSchedulerId(id) { return `plugin:${id}`; }
|
||||||
|
|
||||||
|
const worker = new Worker('discovery', async job => {
|
||||||
|
if (job.name === RUN) {
|
||||||
|
await runPluginJob(job.data && job.data.instanceId);
|
||||||
|
} else if (job.name === GC) {
|
||||||
|
console.log('[Scheduler] Running garbage collection');
|
||||||
|
await DiscoveryReconciler.garbageCollect();
|
||||||
|
}
|
||||||
|
}, { connection });
|
||||||
|
|
||||||
|
// Run one plugin instance. Loads the row (skip silently if it was deleted or
|
||||||
|
// disabled after the job was enqueued), merges its OpenBao secrets into its
|
||||||
|
// config, calls the plugin's run()/discover(), and — for discovery plugins —
|
||||||
|
// reconciles the result into the resource graph under the instance's slug.
|
||||||
|
// Bookkeeping (lastRunAt/lastStatus/lastError) is stamped on the row so the UI
|
||||||
|
// can show run state without querying BullMQ.
|
||||||
|
async function runPluginJob(instanceId) {
|
||||||
|
if (!instanceId) { console.warn('[Scheduler] run_plugin job with no instanceId'); return; }
|
||||||
|
const instance = await PluginInstance.get(instanceId);
|
||||||
|
if (!instance) { console.warn(`[Scheduler] instance ${instanceId} gone — skipping`); return; }
|
||||||
|
if (!instance.enabled) { console.warn(`[Scheduler] instance ${instance.slug} (${instanceId}) disabled — skipping`); return; }
|
||||||
|
|
||||||
|
let mod;
|
||||||
|
try { mod = pluginRegistry.getModule(instance.pluginType); }
|
||||||
|
catch (err) {
|
||||||
|
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} unavailable:`, err.message);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: `plugin type unavailable: ${instance.pluginType}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFn = mod.run || mod.discover;
|
||||||
|
if (typeof runFn !== 'function') {
|
||||||
|
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} has no run()/discover()`);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: 'plugin type has no run()/discover()' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
|
||||||
|
let logs = [];
|
||||||
|
try {
|
||||||
|
const cfg = await pluginSecrets.mergeForRun(instance);
|
||||||
|
cfg.log = (msg) => {
|
||||||
|
logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||||||
|
console.log(`[Plugin ${instance.slug}] ${msg}`);
|
||||||
|
if (logs.length > 1000) logs.shift();
|
||||||
|
};
|
||||||
|
const payload = await runFn(cfg);
|
||||||
|
if (instance.category === 'discovery') {
|
||||||
|
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
||||||
|
}
|
||||||
|
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
||||||
|
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule one instance: upsert a repeatable JobScheduler keyed by its id. Does
|
||||||
|
// NOT trigger an immediate run — call runInstanceNow(id) separately for that
|
||||||
|
// (used on boot and on "load"). Safe to call repeatedly (upsert is idempotent
|
||||||
|
// and will update the cron if it changed).
|
||||||
|
async function scheduleInstance(instance) {
|
||||||
|
if (!instance || !instance.id) return;
|
||||||
|
if (!instance.enabled) { await unscheduleInstance(instance.id); return; }
|
||||||
|
const cron = instance.cron || '0 * * * *';
|
||||||
|
await discoveryQueue.upsertJobScheduler(pluginSchedulerId(instance.id), { pattern: cron }, {
|
||||||
|
name: RUN,
|
||||||
|
data: { instanceId: instance.id }
|
||||||
|
});
|
||||||
|
console.log(`[Scheduler] Scheduled instance ${instance.slug} with cron ${cron}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an instance's repeatable schedule. No-op if it had none.
|
||||||
|
async function unscheduleInstance(id) {
|
||||||
|
if (!id) return;
|
||||||
|
try { await discoveryQueue.removeJobScheduler(pluginSchedulerId(id)); }
|
||||||
|
catch (err) { /* missing scheduler is fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue a single immediate run for an instance (the "Run now" button / boot
|
||||||
|
// kick). Runs once regardless of enabled, on top of any schedule.
|
||||||
|
async function runInstanceNow(id) {
|
||||||
|
if (!id) return;
|
||||||
|
await discoveryQueue.add(RUN, { instanceId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time legacy migration: if the PluginInstance table is empty AND
|
||||||
|
// conf.discovery.plugins has entries (the old static-config shape), seed one
|
||||||
|
// instance per configured type and copy its secret fields into OpenBao. After
|
||||||
|
// the first boot, the table is non-empty and the static config is ignored.
|
||||||
|
// Idempotent (guarded by the empty-table check).
|
||||||
|
async function migrateLegacyPlugins(discoveryConfig) {
|
||||||
|
const existing = await PluginInstance.list();
|
||||||
|
if (existing && existing.length) return;
|
||||||
|
|
||||||
|
const legacy = discoveryConfig && discoveryConfig.plugins;
|
||||||
|
if (!legacy || typeof legacy !== 'object') return;
|
||||||
|
const names = Object.keys(legacy);
|
||||||
|
if (!names.length) return;
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Migrating ${names.length} legacy discovery plugin(s) to instances…`);
|
||||||
|
for (const name of names) {
|
||||||
|
const entry = legacy[name] || {};
|
||||||
|
const manifest = pluginRegistry.getManifest(name);
|
||||||
|
if (!manifest) {
|
||||||
|
console.warn(`[Scheduler] legacy plugin '${name}' has no registered type — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// splitConfig keeps only declared configSchema fields and separates secret
|
||||||
|
// from non-secret. Legacy `enabled`/`cron` are not in configSchema, so they
|
||||||
|
// are dropped here and read from the entry directly below.
|
||||||
|
const { config, secrets } = pluginRegistry.splitConfig(name, entry);
|
||||||
|
const instance = await PluginInstance.create({
|
||||||
|
pluginType: name,
|
||||||
|
category: manifest.category,
|
||||||
|
name: manifest.name,
|
||||||
|
slug: name,
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
cron: entry.cron || '0 * * * *',
|
||||||
|
config,
|
||||||
|
created_by: 'legacy-migration'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pluginSecrets.write(instance.id, secrets);
|
||||||
|
console.log(`[Scheduler] migrated '${name}' -> instance ${instance.id} (slug ${instance.slug})`);
|
||||||
|
} catch (err) {
|
||||||
|
// The instance row exists; if we can't write secrets (e.g. the sso-broker
|
||||||
|
// policy predates theta-suite v1.30.1) the operator gets a clear error
|
||||||
|
// from the API on edit, and the instance still runs with its non-secret
|
||||||
|
// config. Don't delete the row — the operator just needs to re-run
|
||||||
|
// setup.sh and edit/save the secrets.
|
||||||
|
console.error(`[Scheduler] migrated '${name}' row but FAILED to write secrets:`, err.message);
|
||||||
|
await instance.update({ lastStatus: STATUS.ERROR, lastError: `secret migration failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot-time initialization: clear stale schedulers, schedule garbage collection,
|
||||||
|
// migrate any legacy static-config plugins, then schedule every enabled
|
||||||
|
// instance and kick one immediate run for each.
|
||||||
|
async function initScheduler(discoveryConfig) {
|
||||||
|
// Clear stale plugin/gc schedulers from a previous boot. Other-named
|
||||||
|
// schedulers (none in this app) are left alone.
|
||||||
|
try {
|
||||||
|
const schedulers = await discoveryQueue.getJobSchedulers();
|
||||||
|
for (const s of schedulers) {
|
||||||
|
if (s.name === RUN || s.name === GC) {
|
||||||
|
await discoveryQueue.removeJobScheduler(s.key || s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Scheduler] Could not clear old job schedulers:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily garbage collection of stale discovery resources.
|
||||||
|
await discoveryQueue.upsertJobScheduler(GC, { pattern: '0 0 * * *' }, { name: GC, data: {} });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateLegacyPlugins(discoveryConfig);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scheduler] legacy migration failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = await PluginInstance.listEnabled();
|
||||||
|
for (const instance of enabled) {
|
||||||
|
await scheduleInstance(instance);
|
||||||
|
await runInstanceNow(instance.id); // boot kick
|
||||||
|
}
|
||||||
|
console.log(`[Scheduler] initialized — ${enabled.length} instance(s) scheduled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initScheduler,
|
||||||
|
scheduleInstance,
|
||||||
|
unscheduleInstance,
|
||||||
|
runInstanceNow,
|
||||||
|
discoveryQueue,
|
||||||
|
connection
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Directory discovery API — security + contract regression coverage.
|
||||||
|
//
|
||||||
|
// These tests run under the jest + docker harness (redis + the test seed).
|
||||||
|
// They lock in the two fixes from the @simpleworkjs/directory-schema release:
|
||||||
|
// 1. /api/discovery/* returns the { results } envelope (not a bare array —
|
||||||
|
// the drift that made jump-host's `data.results || []` collapse to []).
|
||||||
|
// 2. No response path leaks secret metadata (e.g. an OAuth client's
|
||||||
|
// client_secret_hash), regardless of caller.
|
||||||
|
//
|
||||||
|
// The core assertions hold for any authenticated caller. The admin-projection
|
||||||
|
// assertion (fullMetadata for directory admins) additionally requires the `test`
|
||||||
|
// seed user to be a member of app_sso_directory_admin — see setup.js.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertNoSecrets(results, path) {
|
||||||
|
for (const r of results || []) {
|
||||||
|
// toBeUndefined() in this jest version takes no message arg, so assert
|
||||||
|
// manually and throw with context — this also surfaces the leaked value
|
||||||
|
// if the projection ever regresses.
|
||||||
|
const secretHash = r.metadata && r.metadata.client_secret_hash;
|
||||||
|
if (secretHash !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.metadata) {
|
||||||
|
for (const k of Object.keys(r.metadata)) {
|
||||||
|
if (/secret|password|privatekey/i.test(k)) {
|
||||||
|
throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Discovery — envelope + security', () => {
|
||||||
|
test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
expect(Array.isArray(res.body)).toBe(false); // never a bare array
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources never leaks client_secret_hash', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
assertNoSecrets(res.body.results, '/resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/discovery/resources?group=host_web01_access')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/graph').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.results.resources)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results.resources, '/graph');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results, '/me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => {
|
||||||
|
// Seed-dependent: pick the first slug from the list, then fetch it.
|
||||||
|
const list = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
const slug = list.body.results[0] && list.body.results[0].slug;
|
||||||
|
if (!slug) return; // empty seed — skip rather than fail
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/discovery/resources/${encodeURIComponent(slug)}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(res.body.results.slug).toBe(slug);
|
||||||
|
assertNoSecrets([res.body.results], '/resources/:slug');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => {
|
||||||
|
// If the seed `test` user is a directory admin, /resources should keep
|
||||||
|
// admin-only (non-secret) metadata like redirect_uris/token_lifetime for
|
||||||
|
// them. If not, this assertion is skipped — the no-secrets assertion above
|
||||||
|
// already covers the security guarantee for every caller.
|
||||||
|
test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token);
|
||||||
|
const oauth = (res.body.results || []).find(r => r.kind === 'oauth');
|
||||||
|
if (!oauth) return; // no oauth resource seeded
|
||||||
|
// Only meaningful if the caller is an admin; non-admins correctly get
|
||||||
|
// the public allowlist (no redirect_uris). We assert the absence of
|
||||||
|
// secrets regardless, and skip the positive admin check without a known
|
||||||
|
// admin seed.
|
||||||
|
expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
|||||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: adding/removing a member here didn't clear User's LRU
|
||||||
|
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||||
|
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||||
|
// stay wrong for up to 5 minutes after the group change. In production
|
||||||
|
// this hid a real person's account from the Users page's "People" tab
|
||||||
|
// (it filters out anything with isServiceAccount) for however long the
|
||||||
|
// stale cache entry lived, which looked exactly like the account had
|
||||||
|
// vanished.
|
||||||
|
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||||
|
const added = await request(app)
|
||||||
|
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||||
|
|
||||||
|
const removed = await request(app)
|
||||||
|
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Groups — owner management', () => {
|
describe('Groups — owner management', () => {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Nested groups: the API for putting a group inside a group, the cycle guard,
|
||||||
|
// and the thing that makes it worth doing -- membership resolving transitively
|
||||||
|
// through the chain.
|
||||||
|
//
|
||||||
|
// Fixture note that is easy to get wrong: groupOfNames requires at least one
|
||||||
|
// member, so whoever creates a group is seeded into it. `test` creates all
|
||||||
|
// three groups here and would therefore be a *direct* member of each, which
|
||||||
|
// would make "resolved via nesting" indistinguishable from "was already in it".
|
||||||
|
// Setup below strips that back so test's only direct membership is the
|
||||||
|
// innermost group -- and the strip has to happen after nesting, or removing the
|
||||||
|
// sole member would violate the objectClass.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const A = `nesttest-a-${stamp}`; // outermost
|
||||||
|
const B = `nesttest-b-${stamp}`; // middle
|
||||||
|
const C = `nesttest-c-${stamp}`; // innermost, holds the user
|
||||||
|
// A second group nested into A purely so that un-nesting B later does not
|
||||||
|
// empty A -- groupOfNames requires at least one member, and the API correctly
|
||||||
|
// refuses (409) rather than leaving an invalid entry behind.
|
||||||
|
const D = `nesttest-d-${stamp}`;
|
||||||
|
|
||||||
|
async function nest(parent, child) {
|
||||||
|
return request(app).put(`/api/group/${parent}/nested/${child}`).set('auth-token', token).send({});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
|
||||||
|
for (const cn of [A, B, C, D]) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/group')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: cn, description: `nesting test ${cn}` });
|
||||||
|
expect([200, 201]).toContain(res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await nest(A, B)).status).toBe(200);
|
||||||
|
expect((await nest(B, C)).status).toBe(200);
|
||||||
|
expect((await nest(A, D)).status).toBe(200);
|
||||||
|
|
||||||
|
// Now that A holds B and B holds C, neither would be left memberless.
|
||||||
|
for (const cn of [A, B]) {
|
||||||
|
const res = await request(app).delete(`/api/group/${cn}/test`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — API guards', () => {
|
||||||
|
test('nesting the same pair twice is a 409, not a duplicate', async () => {
|
||||||
|
const res = await nest(A, B);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a group cannot contain itself', async () => {
|
||||||
|
const res = await nest(A, A);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard that matters: without it the resolver would silently return a
|
||||||
|
// depth-capped answer instead of an error anyone would notice.
|
||||||
|
test('a direct cycle is refused (A contains B, so B may not contain A)', async () => {
|
||||||
|
const res = await nest(B, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/loop/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an indirect cycle is refused too (A>B>C, so C may not contain A)', async () => {
|
||||||
|
const res = await nest(C, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — resolution', () => {
|
||||||
|
test('membership resolves through the whole chain', async () => {
|
||||||
|
const res = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toContain(C); // direct
|
||||||
|
expect(res.body.results).toContain(B); // via C
|
||||||
|
expect(res.body.results).toContain(A); // via B -> C
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:group/effective separates direct members from nested ones', async () => {
|
||||||
|
const res = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { direct, nestedGroups, effective } = res.body.results;
|
||||||
|
|
||||||
|
expect(nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
// `direct` is users only -- a nested group must never be reported as one.
|
||||||
|
expect(direct.every(dn => !/,ou=groups,/i.test(dn))).toBe(true);
|
||||||
|
// test is not listed on A at all, yet is effectively a member two levels down.
|
||||||
|
expect(direct.some(dn => /cn=test,/i.test(dn))).toBe(false);
|
||||||
|
expect(effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — un-nesting', () => {
|
||||||
|
test('DELETE removes the nesting and the membership it carried', async () => {
|
||||||
|
// Before: A holds B (which holds C, which holds test) and D.
|
||||||
|
const before = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(before.body.results.nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
expect(before.body.results.effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${A}/nested/${B}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).not.toContain(B);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).toContain(D); // untouched
|
||||||
|
|
||||||
|
// test still resolves to B and C directly/through C; only the A path via
|
||||||
|
// B is gone. It is deliberately NOT asserted that test loses A entirely:
|
||||||
|
// D is also nested in A and test created D, so that path remains -- which
|
||||||
|
// is itself a fair illustration of why "who can reach this" has to be
|
||||||
|
// computed rather than eyeballed.
|
||||||
|
const groups = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(groups.body.results).toContain(C);
|
||||||
|
expect(groups.body.results).toContain(B);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un-nesting the last member is refused rather than emptying the group', async () => {
|
||||||
|
// B now holds only C. Removing it would leave B with no members at all,
|
||||||
|
// which groupOfNames forbids.
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${B}/nested/${C}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/at least one member/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// directory.ejs's "Rotate Client Secret" — it froze the tab entirely) and are
|
||||||
|
// visually inconsistent with the rest of the UI. Every call site was removed
|
||||||
|
// in favor of app.messages.action/confirm/toast and app.modal.open; this test
|
||||||
|
// keeps it that way.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', d));
|
||||||
|
|
||||||
|
// Matches a bare alert(/confirm(/prompt( call, but not app.messages.*,
|
||||||
|
// app.modal.*, or identifiers merely containing these words (e.g.
|
||||||
|
// "confirmation", ".confirmed").
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -69,6 +69,51 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
|||||||
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT persists — a changed name survives a fresh GET', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const updated = await request(app)
|
||||||
|
.put(`/api/oauth/client/${id}`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test-renamed' });
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: this route called client.remove(), but OAuthClient wraps
|
||||||
|
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
|
||||||
|
// — .remove() doesn't exist on it (unlike the model-redis Tables
|
||||||
|
// elsewhere in this app, e.g. api_token.js, which really do have
|
||||||
|
// .remove()). The route's try/catch turned the resulting TypeError into
|
||||||
|
// a plain 500 JSON response rather than a thrown exception, so every
|
||||||
|
// prior DELETE call in this file's cleanup hooks silently "succeeded"
|
||||||
|
// from Jest's point of view while leaving the client un-deleted.
|
||||||
|
test('DELETE persists — the client is actually gone, not just a 200', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||||
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||||
// find by name, rotate by the client_id from the list response. Uses a
|
// find by name, rotate by the client_id from the list response. Uses a
|
||||||
@@ -90,7 +135,11 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
|||||||
expect(rotated.status).toBe(200);
|
expect(rotated.status).toBe(200);
|
||||||
expect(rotated.body.client_secret).toBeTruthy();
|
expect(rotated.body.client_secret).toBeTruthy();
|
||||||
|
|
||||||
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(afterDelete.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET /:id unknown id returns 404, not 500', async () => {
|
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-25
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||||
|
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
|
||||||
|
// cwd is utils/ for the bare-metal git fallback.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
version,
|
||||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
function readBuildHash() {
|
});
|
||||||
try {
|
|
||||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
|
||||||
if (baked) return baked;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
|
||||||
} catch (_) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildVersion,
|
|
||||||
buildHash: readBuildHash(),
|
|
||||||
buildYear: new Date().getFullYear(),
|
|
||||||
};
|
|
||||||
@@ -2,16 +2,30 @@
|
|||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
let byGroup = async function(user, groups, ownerOf){
|
||||||
for(let group of groups){
|
// Membership is resolved once, transitively: a user placed in an admin group
|
||||||
try{
|
// through a nested group is as much a member as one listed on it directly.
|
||||||
group = await Group.get(group);
|
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||||
if(group.member.includes(user.dn)) return true
|
// only ever sees the literal member list and would deny them.
|
||||||
}catch(error){
|
let memberOfCns = [];
|
||||||
// group not found, continue checking
|
try{
|
||||||
}
|
memberOfCns = await Group.list(user.dn);
|
||||||
|
}catch(error){
|
||||||
|
// Fall through to the per-group checks below rather than hard-failing;
|
||||||
|
// they still catch direct membership if the resolver is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||||
|
|
||||||
|
for(let group of groups){
|
||||||
|
if(memberOfCns.includes(group)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `owner` is deliberately NOT transitive. It designates accountable people,
|
||||||
|
// and inheriting ownership through a nested group would hand approval rights
|
||||||
|
// to anyone transitively in it -- an escalation nobody asked for.
|
||||||
for(let group of ownerOf || []){
|
for(let group of ownerOf || []){
|
||||||
try{
|
try{
|
||||||
group = await Group.get(group);
|
group = await Group.get(group);
|
||||||
@@ -28,4 +42,4 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {byGroup};
|
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||||
|
|||||||
@@ -0,0 +1,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 };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/sso-manager-node',
|
||||||
|
licenseUrl: 'https://github.com/theta42/sso-manager-node/blob/master/LICENSE',
|
||||||
|
// In-app docs route (routes/docs.js). Apps without one point at the
|
||||||
|
// published docs site and set docsExternal.
|
||||||
|
docsUrl: '/docs',
|
||||||
|
docsExternal: false,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: '/tos',
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: conf.logo,
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: '/profile',
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: true,
|
||||||
|
updateLabel: 'SSO Manager',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
// Ungated on purpose: the catalog is the one page that exists for
|
||||||
|
// ordinary users. Before this, every nav item was admin-only and a
|
||||||
|
// non-admin had no signposted destination at all.
|
||||||
|
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
|
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||||
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
|
{href: '/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,
|
||||||
|
};
|
||||||
+10
-5
@@ -1,5 +1,8 @@
|
|||||||
</div><!-- end spa-shell -->
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@@ -7,19 +10,21 @@
|
|||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</a>
|
</a>
|
||||||
© <%- buildYear %> theta42 ·
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
</span>
|
</span>
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user