Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 522093e898 | |||
| 7b84a10420 | |||
| c461723ec7 | |||
| b948cd8625 | |||
| 36aa114d7c | |||
| fbce59b1be | |||
| 554a0999ab | |||
| 3ca221d64d | |||
| 75b133f610 | |||
| f1d52601de | |||
| ecd21c4984 | |||
| 5ba2ace835 | |||
| 25b0d57a97 |
@@ -1303,6 +1303,39 @@ Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, o
|
|||||||
|
|
||||||
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
**`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:
|
||||||
|
|||||||
@@ -1,9 +1,111 @@
|
|||||||
|
## 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
|
## [1.17.0] - 2026-08-01
|
||||||
|
|
||||||
A real **plugin system**: the half-built discovery plugins (statically
|
A real **plugin system**: the half-built discovery plugins (statically
|
||||||
|
|||||||
@@ -1,512 +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.
|
|
||||||
|
|
||||||
> **Note — the bundled slapd is built from source.** The all-in-one image
|
|
||||||
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
|
|
||||||
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
|
|
||||||
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
|
|
||||||
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
|
|
||||||
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
|
|
||||||
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
|
|
||||||
> shape as "Restore — LDAP only" above. Verify after a rebuild:
|
|
||||||
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
|
|
||||||
> as available.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Method 2: Bare metal (Debian/Ubuntu)
|
|
||||||
|
|
||||||
`install.sh` is an idempotent installer: it installs Node.js 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.
|
|
||||||
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
|
|||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -1,55 +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: Plugins
|
|
||||||
page: /plugins.html
|
|
||||||
icon: fa-plug
|
|
||||||
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
|
|
||||||
# api.html for it — link the source directly, same as the Changelog.
|
|
||||||
- title: API
|
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/API.md
|
|
||||||
icon: fa-code
|
|
||||||
- title: Changelog
|
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
|
||||||
icon: fa-list
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
- scope:
|
|
||||||
path: ""
|
|
||||||
type: "pages"
|
|
||||||
values:
|
|
||||||
layout: default
|
|
||||||
image: /assets/img/theta42.svg
|
|
||||||
@@ -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,88 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Discovery Agents
|
|
||||||
nav_order: 5
|
|
||||||
---
|
|
||||||
|
|
||||||
# Discovery Agents
|
|
||||||
|
|
||||||
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
|
||||||
|
|
||||||
## Writing a Custom Agent
|
|
||||||
|
|
||||||
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
|
||||||
|
|
||||||
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
|
||||||
|
|
||||||
### Agent Skeleton
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// nodejs/agents/discovery/my_custom_agent.js
|
|
||||||
module.exports = {
|
|
||||||
discover: async (config) => {
|
|
||||||
const { url, apiKey } = config; // Provided by your configuration
|
|
||||||
|
|
||||||
const resources = [];
|
|
||||||
const edges = [];
|
|
||||||
|
|
||||||
// 1. Fetch your data from an API
|
|
||||||
// const data = await fetch(...);
|
|
||||||
|
|
||||||
// 2. Map data to Resources
|
|
||||||
resources.push({
|
|
||||||
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
|
||||||
name: 'My Switch',
|
|
||||||
slug: 'my-switch-01',
|
|
||||||
metadata: {
|
|
||||||
make: 'Vendor',
|
|
||||||
model: 'Model X',
|
|
||||||
interfaces: [
|
|
||||||
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Map relations to Edges (optional)
|
|
||||||
edges.push({
|
|
||||||
parentSlug: 'my-switch-01',
|
|
||||||
childSlug: 'some-connected-client-slug',
|
|
||||||
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
|
||||||
});
|
|
||||||
|
|
||||||
return { resources, edges };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
module.exports = {
|
|
||||||
// ... existing config ...
|
|
||||||
discovery: {
|
|
||||||
agents: {
|
|
||||||
my_custom_agent: {
|
|
||||||
enabled: true,
|
|
||||||
cron: '*/30 * * * *', // Run every 30 minutes
|
|
||||||
url: 'https://api.example.com',
|
|
||||||
apiKey: 'secret-key'
|
|
||||||
},
|
|
||||||
nmap: {
|
|
||||||
enabled: true,
|
|
||||||
cron: '0 * * * *',
|
|
||||||
targetRange: '192.168.1.0/24'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Reconciliation Engine
|
|
||||||
|
|
||||||
When your agent returns its graph, the Reconciliation Engine takes over:
|
|
||||||
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
|
||||||
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
|
|
||||||
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
|
||||||
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
|
||||||
@@ -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,125 +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/Directory/Overview
|
|
||||||
pages at all), but you can also make your own groups for any app you
|
|
||||||
connect — say, a group listing everyone who should be allowed into your
|
|
||||||
photo server. Once a group exists, add or remove members from the
|
|
||||||
**Groups** page, and point the other app's "who's allowed in" setting at
|
|
||||||
that group's name.
|
|
||||||
|
|
||||||
### Groups inside groups
|
|
||||||
|
|
||||||
A group can contain another group, not just people — the *Nested* tab on any
|
|
||||||
group card. Everyone in the inner group counts as a member of the outer one,
|
|
||||||
however many levels deep it goes.
|
|
||||||
|
|
||||||
This is mostly a way to stop repeating yourself. Make one `developers` group,
|
|
||||||
nest it into the handful of things developers should reach, and adding a new
|
|
||||||
developer to that one group grants all of them at once — instead of adding them
|
|
||||||
to each individually and slowly drifting out of sync. The app already does this
|
|
||||||
for itself: super admins are nested into every resource's admin group, and each
|
|
||||||
admin group into its access group, so "can administer it" always implies "can
|
|
||||||
use it".
|
|
||||||
|
|
||||||
Two things it won't let you do: put a group inside itself (directly or round a
|
|
||||||
longer loop), and empty a group completely — every group must keep at least one
|
|
||||||
member.
|
|
||||||
|
|
||||||
A note if you also manage the directory by hand: a group's member list shows
|
|
||||||
what is *directly* listed on it. Someone who gets in through a nested group is
|
|
||||||
a real member but won't appear there — the **Nested** tab shows what is nested,
|
|
||||||
and the API's `effective` view lists everyone who actually gets in.
|
|
||||||
|
|
||||||
## Every account's personal group
|
|
||||||
|
|
||||||
Separately from the groups above, every single account — person or
|
|
||||||
service account — automatically gets its own small, personal group when
|
|
||||||
it's created, named after the account itself. Most of the time you'll
|
|
||||||
never think about this; it exists so that, on a Linux system connected to
|
|
||||||
this directory, each account "owns" its own files by default the same way
|
|
||||||
a normal Unix user account would.
|
|
||||||
|
|
||||||
Occasionally you'll want to share that ownership with someone else — for
|
|
||||||
example, letting a second account also have write access to files a
|
|
||||||
service account owns. That's what the **"Members of `<uid>`'s group"**
|
|
||||||
section on a profile page is for: add another account there, and the
|
|
||||||
underlying Linux permissions treat them as if they belong to that same
|
|
||||||
personal group too.
|
|
||||||
|
|
||||||
## What's a "manager"?
|
|
||||||
|
|
||||||
Every account has one or more **managers** — the people allowed to edit
|
|
||||||
that account's profile (phone number, SSH key, home directory, and so on)
|
|
||||||
without needing full admin rights. By default, whoever created an account
|
|
||||||
(the admin who added it, or whoever sent the invite) becomes its first
|
|
||||||
manager, but you can add or remove managers later from the account's Edit
|
|
||||||
form.
|
|
||||||
|
|
||||||
This is useful for service accounts especially: if a service account
|
|
||||||
belongs to a particular project or person, make them its manager so they
|
|
||||||
can maintain it — rotate its SSH key, adjust its description — without
|
|
||||||
needing to be a full SSO administrator.
|
|
||||||
|
|
||||||
## Inviting someone vs. adding them yourself
|
|
||||||
|
|
||||||
From the Users page you can either fill in someone's details yourself
|
|
||||||
("Add new user"), or send them an **invite** — an email (or a link you copy
|
|
||||||
and send however you like) that lets them pick their own username and
|
|
||||||
password. Either way, the resulting account is identical; invites are just
|
|
||||||
a convenience so you don't have to know someone's preferred username or
|
|
||||||
handle their password directly.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page deliberately leaves out LDAP schema names, attribute types, and
|
|
||||||
protocol-level detail. If you're connecting a third-party app directly to
|
|
||||||
the LDAP directory, or you just want to know exactly what's stored where,
|
|
||||||
see the [LDAP reference](ldap.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -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](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
|
||||||
|
|
||||||
## What's an API token, in plain terms?
|
|
||||||
|
|
||||||
Normally, you interact with this app by logging in through a web browser.
|
|
||||||
An **API token** (also called a personal access token, or PAT) is an
|
|
||||||
alternative way in — a long, random string that a script, a scheduled job,
|
|
||||||
or another program can use instead of a username and password, to act on
|
|
||||||
your behalf without a human typing a login in each time.
|
|
||||||
|
|
||||||
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
|
||||||
service using a "token" instead of your real password, this is the same
|
|
||||||
idea.
|
|
||||||
|
|
||||||
## When would you actually need one?
|
|
||||||
|
|
||||||
Most people never need to create one of these — you'll only want a token
|
|
||||||
if you're automating something, for example:
|
|
||||||
|
|
||||||
- A script that syncs users or groups from somewhere else into this SSO
|
|
||||||
Manager on a schedule.
|
|
||||||
- A backup or monitoring job that checks this app's health via its API.
|
|
||||||
- A CI/CD pipeline that needs to register or update an OAuth client
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
If you're not doing any of that, you don't need an API token — just log in
|
|
||||||
normally through the web UI.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Create a token from your Profile page, give it a name so you remember what
|
|
||||||
it's for later, and optionally an expiry. You'll be shown the token's
|
|
||||||
value **exactly once** — copy it somewhere safe immediately, because it
|
|
||||||
can't be viewed again afterward (only revoked or rotated). Whatever script
|
|
||||||
or tool you're using it with sends it along with each request, the same
|
|
||||||
way a browser sends your login session.
|
|
||||||
|
|
||||||
A token acts **as you**, with **your** permissions — if you're not an
|
|
||||||
admin, a token you create can't do admin-only things either. If you ever
|
|
||||||
suspect a token has leaked (ended up somewhere it shouldn't have, like a
|
|
||||||
public script or log file), revoke it immediately from your Profile page;
|
|
||||||
it stops working right away.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page doesn't attempt to list every API endpoint or show request/
|
|
||||||
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -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
|
|
||||||
in the Directory representing that one app. Registering a client
|
|
||||||
gives you a **Client ID** and **Client Secret**: think of these like a
|
|
||||||
username and password, but for the *app itself* rather than for a person.
|
|
||||||
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
|
||||||
screen, along with the discovery URL shown at the top of this page, and
|
|
||||||
that app is now able to ask this SSO Manager to authenticate people on its
|
|
||||||
behalf.
|
|
||||||
|
|
||||||
**Treat the Client Secret like a password** — anyone who has it can
|
|
||||||
impersonate that app when talking to your SSO Manager. If you ever suspect
|
|
||||||
it's leaked, rotate it from the client's card.
|
|
||||||
|
|
||||||
## What are "scopes"?
|
|
||||||
|
|
||||||
**Scopes** control what information a connected app is allowed to ask for
|
|
||||||
about the person logging in — their username, email, group memberships,
|
|
||||||
and so on. Most apps tell you exactly which scopes they need in their own
|
|
||||||
setup instructions; when in doubt, the default set (`openid`, `profile`,
|
|
||||||
`email`, `groups`) covers what nearly every app expects.
|
|
||||||
|
|
||||||
## "Restrict to Groups"
|
|
||||||
|
|
||||||
By default, *any* account with an SSO Manager login can sign into a
|
|
||||||
connected app. If that's not what you want — say, a home automation
|
|
||||||
dashboard that only certain family members should reach — set **Restrict
|
|
||||||
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
|
||||||
Only members of that group will be allowed to log into that particular
|
|
||||||
app; everyone else gets turned away at the login step, even though their
|
|
||||||
SSO Manager account still works everywhere else.
|
|
||||||
|
|
||||||
## Redirect URIs
|
|
||||||
|
|
||||||
A **Redirect URI** is the exact web address the connected app wants people
|
|
||||||
sent back to once they've logged in here — it's a security measure so an
|
|
||||||
attacker can't trick the login flow into redirecting somewhere else. The
|
|
||||||
app's own setup instructions will tell you this value; copy it in exactly
|
|
||||||
as given. If the app is reachable via more than one hostname (for example,
|
|
||||||
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
|
|
||||||
this field supports wildcard patterns — see the inline help under the
|
|
||||||
field itself for the exact syntax.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page intentionally skips the protocol-level detail (exact endpoint
|
|
||||||
URLs, token formats, claim names). If you're troubleshooting a connection
|
|
||||||
or building something against the API directly, see the
|
|
||||||
[OAuth reference](oauth.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -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,139 +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`).
|
|
||||||
|
|
||||||
### Who sees which metadata
|
|
||||||
|
|
||||||
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
|
|
||||||
|
|
||||||
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
|
|
||||||
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
|
|
||||||
- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
|
|
||||||
|
|
||||||
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
|
|
||||||
|
|
||||||
## Catalog & access requests
|
|
||||||
|
|
||||||
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
|
|
||||||
|
|
||||||
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
|
|
||||||
- **Discover More** — everything else in the directory, with a **Request access** button.
|
|
||||||
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
|
|
||||||
|
|
||||||
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
|
|
||||||
|
|
||||||
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
|
|
||||||
|
|
||||||
## Navigating the UI
|
|
||||||
|
|
||||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
|
||||||
|
|
||||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
|
||||||
|
|
||||||
## Slug conventions
|
|
||||||
|
|
||||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
|
||||||
|
|
||||||
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
|
||||||
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
|
||||||
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
|
||||||
|
|
||||||
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
|
|
||||||
|
|
||||||
## Automatic registration
|
|
||||||
|
|
||||||
You don't have to build the graph by hand — the theta42 tooling registers itself:
|
|
||||||
|
|
||||||
### The stack itself (theta-env)
|
|
||||||
|
|
||||||
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
|
|
||||||
|
|
||||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
|
||||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
|
||||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
|
||||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
|
||||||
|
|
||||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
|
||||||
|
|
||||||
### Linux hosts (ldap-client)
|
|
||||||
|
|
||||||
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
|
|
||||||
|
|
||||||
## Consumers of the directory
|
|
||||||
|
|
||||||
The inventory graph isn't just documentation — other components read it to make decisions:
|
|
||||||
|
|
||||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
|
|
||||||
|
|
||||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
|
||||||
|
|
||||||
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
|
||||||
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
|
||||||
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
|
||||||
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
|
|
||||||
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
|
|
||||||
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
|
||||||
|
|
||||||
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
|
|
||||||
|
|
||||||
- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}`
|
|
||||||
- `GET /api/access-requests/mine` — the caller's own history
|
|
||||||
- `GET /api/access-requests` — pending requests the caller may decide
|
|
||||||
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
|
|
||||||
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
|
|
||||||
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 221 KiB |
@@ -1,85 +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="Overview dashboard" width="49%"></a>
|
|
||||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
|
||||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
|
||||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
|
||||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
|
||||||
|
|
||||||
*(click any screenshot to view full size)*
|
|
||||||
|
|
||||||
## Why this over the alternatives
|
|
||||||
|
|
||||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
|
||||||
LDAP is either a paid feature, a federation target you have to run
|
|
||||||
separately, or absent. If your stack already has apps that speak LDAP
|
|
||||||
directly — or you just want one real directory as the source of truth — you
|
|
||||||
end up running *two* identity systems and keeping them in sync.
|
|
||||||
|
|
||||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
|
||||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
|
||||||
scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
|
|
||||||
If you want a lightweight, self-contained identity provider with a real LDAP
|
|
||||||
backend, that's the niche.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
|
|
||||||
tokens; standard discovery document at `/.well-known/openid-configuration`.
|
|
||||||
- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
|
|
||||||
keys, and sudo roles, with `memberOf` + referential-integrity overlays.
|
|
||||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
|
||||||
invite and password-reset flows over email; self-service profile + API
|
|
||||||
tokens.
|
|
||||||
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
|
||||||
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
|
||||||
directory.
|
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
|
||||||
run the pieces separately via `app_*` env config.
|
|
||||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
|
||||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
|
||||||
|
|
||||||
## Get it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/sso-manager-node.git
|
|
||||||
cd sso-manager-node
|
|
||||||
cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the standalone quick start. For the full set of install options
|
|
||||||
(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
|
|
||||||
`app_*` env reference, and the OAuth/LDAP internals, see the
|
|
||||||
**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
|
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
|
||||||
reverse proxy, designed to sit in front of this SSO.
|
|
||||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
|
||||||
uses this SSO's directory to decide who may reach which machine.
|
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
|
||||||
Manager and the proxy together with one command.
|
|
||||||
@@ -1,432 +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.
|
|
||||||
|
|
||||||
Note that `groupOfNames` requires **at least one member**, which has two
|
|
||||||
consequences worth knowing: whoever creates a group is automatically seeded
|
|
||||||
into it, and removing the last member (user *or* nested group) is refused with
|
|
||||||
a 409 rather than leaving an invalid entry behind.
|
|
||||||
|
|
||||||
### Nested groups
|
|
||||||
|
|
||||||
A `member` DN may be another group's, not just a user's — that is how nesting
|
|
||||||
is stored, with no extra schema. Everyone in the nested group is a member of
|
|
||||||
the outer one, at any depth. Manage it on the **Groups** page under each
|
|
||||||
group's *Nested* tab, or via the API:
|
|
||||||
|
|
||||||
```
|
|
||||||
PUT /api/group/:group/nested/:child nest :child inside :group
|
|
||||||
DELETE /api/group/:group/nested/:child un-nest
|
|
||||||
GET /api/group/:group/effective direct users, nested groups, and the
|
|
||||||
full transitive set of users
|
|
||||||
```
|
|
||||||
|
|
||||||
Cycles are refused (409) rather than truncated — a loop makes "who is in this
|
|
||||||
group" unanswerable. Two standing relationships are wired automatically: the
|
|
||||||
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
|
|
||||||
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
|
|
||||||
something implies being able to use it.
|
|
||||||
|
|
||||||
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
|
|
||||||
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
|
|
||||||
direct membership only. The bundled slapd is therefore built from source with
|
|
||||||
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
|
|
||||||
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
|
|
||||||
closure itself — same answers, more queries. Either way, **never read `memberOf`
|
|
||||||
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
|
|
||||||
which is correct in both modes.
|
|
||||||
|
|
||||||
### Personal groups
|
|
||||||
|
|
||||||
Every user (person or service account) also gets a **personal Unix group**
|
|
||||||
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
|
|
||||||
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
|
|
||||||
different schema than the `groupOfNames` groups above — its membership
|
|
||||||
attribute is `memberUid` (a bare username, not a DN), and unlike
|
|
||||||
`groupOfNames` it's valid with zero members. It's excluded from the
|
|
||||||
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
|
|
||||||
instead from the owning user's own profile page ("Members of `<uid>`'s
|
|
||||||
group", admin-only) — add other accounts as supplementary members, e.g. to
|
|
||||||
share write access to files owned by this group.
|
|
||||||
|
|
||||||
The SSO seeds these groups automatically (entrypoint / `install.sh`):
|
|
||||||
|
|
||||||
| Group | Grants |
|
|
||||||
|-------|--------|
|
|
||||||
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
|
|
||||||
| `app_sso_admin` | full admin (users, groups, settings) |
|
|
||||||
| `app_sso_oauth_admin` | OAuth client management |
|
|
||||||
| `app_sso_invite` | invitation management |
|
|
||||||
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
|
|
||||||
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
|
|
||||||
unreleased), so the bundled image builds slapd from a pinned upstream commit.
|
|
||||||
Without it the app resolves nesting itself and everything still works — leave
|
|
||||||
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
|
|
||||||
configure:
|
|
||||||
|
|
||||||
```
|
|
||||||
overlay nestgroup
|
|
||||||
nestgroup-base ou=groups,<base>
|
|
||||||
nestgroup-flags member-filter memberof-filter memberof-values
|
|
||||||
```
|
|
||||||
|
|
||||||
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
|
|
||||||
notation suggests is rejected. `member-values` is deliberately omitted — it
|
|
||||||
expands the `member` attribute when reading a group, which destroys the
|
|
||||||
distinction between "listed here" and "reachable through a nested group", and
|
|
||||||
the raw values are then unrecoverable. Transitive answers come from the filter
|
|
||||||
flags and from `GET /api/group/:group/effective`.
|
|
||||||
|
|
||||||
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
|
|
||||||
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
|
|
||||||
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
|
|
||||||
is a `slapcat` → `slapadd` reload, not a restart.
|
|
||||||
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
|
||||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
|
||||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
|
||||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
|
||||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
|
|
||||||
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
|
|
||||||
also nests it into the first three).
|
|
||||||
|
|
||||||
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
|
||||||
idempotently against a running slapd (auto-detects the database holding your
|
|
||||||
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)
|
|
||||||
@@ -1,112 +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.
|
|
||||||
|
|
||||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Secrets Vault
|
|
||||||
nav_order: 6
|
|
||||||
---
|
|
||||||
|
|
||||||
# Secrets Vault
|
|
||||||
|
|
||||||
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
|
||||||
|
|
||||||
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
|
|
||||||
|
|
||||||
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
|
|
||||||
|
|
||||||
## Accessing the Vault
|
|
||||||
|
|
||||||
The SSO Manager Vault can be accessed in two ways:
|
|
||||||
|
|
||||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
|
|
||||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
|
||||||
|
|
||||||
### API Example
|
|
||||||
|
|
||||||
To read secrets from the default key-value store, issue a `GET` request to:
|
|
||||||
`/api/vault/v1/secret/data/sso-manager/conf`
|
|
||||||
|
|
||||||
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
|
|
||||||
|
|
||||||
## Namespaces and Paths
|
|
||||||
|
|
||||||
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
|
||||||
|
|
||||||
## Plugin Integration
|
|
||||||
|
|
||||||
Plugin instances store their per-instance secrets in OpenBao at
|
|
||||||
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
|
||||||
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
|
||||||
in-process, so the SSO Manager reads/writes those secrets server-side through
|
|
||||||
the `sso-broker` token; the admin UI only ever sees masked values, and external
|
|
||||||
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
|
|
||||||
consistently enforced instead of hardcoding them.
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ 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');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.16.0",
|
"version": "1.19.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.16.0",
|
"version": "1.19.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.17.0",
|
"version": "1.19.2",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"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,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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ module.exports = {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const scan = new nmap.OsAndPortScan(targetRange);
|
const scan = new nmap.OsAndPortScan(targetRange);
|
||||||
|
scan.command.push('-Pn');
|
||||||
scan.on('complete', function(data) {
|
scan.on('complete', function(data) {
|
||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
@@ -66,7 +67,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
scan.on('error', function(error) {
|
scan.on('error', function(error) {
|
||||||
reject(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();
|
scan.startScan();
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ module.exports = {
|
|||||||
|
|
||||||
// 1. Get Nodes
|
// 1. Get Nodes
|
||||||
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
|
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;
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -12,12 +12,33 @@ router.use(async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
router.get('/', async (req, res) => {
|
||||||
const editable = {
|
const editable = maskSecrets({
|
||||||
smtp: conf.smtp || {},
|
smtp: conf.smtp || {},
|
||||||
discovery: conf.discovery || {},
|
discovery: conf.discovery || {},
|
||||||
oauth: conf.oauth || {}
|
oauth: conf.oauth || {},
|
||||||
};
|
voipms: conf.voipms || {}
|
||||||
|
});
|
||||||
res.json(editable);
|
res.json(editable);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,23 +59,73 @@ function applyToLiveConf(src) {
|
|||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const existing = await baoConf.get('sso-manager/conf') || {};
|
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||||
// Deep merge req.body into existing
|
const incoming = req.body || {};
|
||||||
for (const key of Object.keys(req.body)) {
|
|
||||||
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
// Preserve secret fields the admin left blank (or left showing the mask):
|
||||||
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
|
// 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 {
|
} else {
|
||||||
existing[key] = req.body[key];
|
existing[key] = incoming[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await baoConf.set('sso-manager/conf', existing);
|
await baoConf.set('sso-manager/conf', existing);
|
||||||
// Reflect the saved values in the live conf immediately (the next boot's
|
// 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
|
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||||
// current without a restart, as the old conf_manager did).
|
// current without a restart, as the old conf_manager did). `existing`
|
||||||
|
// carries the preserved secret values, so live conf keeps them too.
|
||||||
applyToLiveConf(existing);
|
applyToLiveConf(existing);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
next(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;
|
module.exports = router;
|
||||||
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
|
|||||||
|
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
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
|
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||||
// (app_super_admin is always allowed by permission.byGroup).
|
// (app_super_admin is always allowed by permission.byGroup).
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
|
|||||||
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
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 (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
// 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' });
|
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 +
|
// `config` from the client is a flat object of all field values (secret +
|
||||||
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
pluginType,
|
pluginType,
|
||||||
category: manifest.category,
|
category: manifest.category,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug: finalSlug,
|
||||||
enabled,
|
enabled,
|
||||||
cron: cron || '0 * * * *',
|
cron: cron || '0 * * * *',
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
for (const res of resources) {
|
for (const res of resources) {
|
||||||
if (!res.metadata) res.metadata = {};
|
if (!res.metadata) res.metadata = {};
|
||||||
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
let existing = null;
|
let existing = null;
|
||||||
|
|
||||||
// Attempt matching by MAC if available
|
// Attempt matching by MAC if available (case-insensitive)
|
||||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||||
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
|
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
|
||||||
if (macs.length > 0) {
|
if (macs.length > 0) {
|
||||||
const allRes = await Resource.list();
|
const allRes = await Resource.list();
|
||||||
existing = allRes.find(r =>
|
existing = allRes.find(r =>
|
||||||
r.metadata && r.metadata.interfaces &&
|
r.metadata && r.metadata.interfaces &&
|
||||||
r.metadata.interfaces.some(i => macs.includes(i.mac))
|
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,10 @@ class DiscoveryReconciler {
|
|||||||
const newIntfs = res.metadata.interfaces;
|
const newIntfs = res.metadata.interfaces;
|
||||||
// Simple union based on mac or ip
|
// Simple union based on mac or ip
|
||||||
for (const ni of newIntfs) {
|
for (const ni of newIntfs) {
|
||||||
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip));
|
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 };
|
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
|
||||||
else existingIntfs.push(ni);
|
else existingIntfs.push(ni);
|
||||||
}
|
}
|
||||||
@@ -79,16 +83,23 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
mergedMeta.last_seen = Date.now();
|
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({
|
await existing.update({
|
||||||
name: res.name || existing.name,
|
name: bestName,
|
||||||
description: res.description || existing.description,
|
description: res.description || existing.description,
|
||||||
metadata: mergedMeta,
|
metadata: mergedMeta,
|
||||||
updated_on: Math.floor(Date.now() / 1000)
|
updated_on: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
|
res._actualId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
const sources = [sourceName];
|
const sources = new Set([sourceName]);
|
||||||
res.metadata.discovery_sources = sources;
|
res.metadata.discovery_sources = [...sources];
|
||||||
res.metadata.last_seen = Date.now();
|
res.metadata.last_seen = Date.now();
|
||||||
|
|
||||||
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
@@ -103,12 +114,48 @@ class DiscoveryReconciler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
newDevices++;
|
newDevices++;
|
||||||
|
res._actualId = created.id; // Map original slug to actual ID
|
||||||
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
|
// Now process edges
|
||||||
// or we just trust the plugins to give us explicit parent-child mappings by slug.
|
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) {
|
if (newDevices > 0) {
|
||||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
// 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) {
|
async function ensurePolicy(name, hcl) {
|
||||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
if (existing.status === 200) return;
|
if (existing.status !== 200 && existing.status !== 404) {
|
||||||
if (existing.status !== 404) {
|
|
||||||
const t = await existing.text().catch(() => '');
|
const t = await existing.text().catch(() => '');
|
||||||
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,12 @@ async function mintToken(policies) {
|
|||||||
function userPolicyHcl(uid) {
|
function userPolicyHcl(uid) {
|
||||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||||
// into a policy path, so reject anything but a safe charset.
|
// 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"] }
|
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"] }`;
|
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +117,10 @@ async function getOrCreateAdminToken(uid) {
|
|||||||
|
|
||||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
function appPolicyHcl(name) {
|
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"] }
|
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"] }`;
|
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadConf();
|
loadConf();
|
||||||
|
loadProxyConf();
|
||||||
|
loadTos();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConf() {
|
async function loadConf() {
|
||||||
@@ -28,6 +30,13 @@
|
|||||||
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
$('#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) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
@@ -53,6 +62,11 @@
|
|||||||
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
||||||
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
voipms: {
|
||||||
|
username: $('#voipms-username').val(),
|
||||||
|
did: $('#voipms-did').val(),
|
||||||
|
password: $('#voipms-password').val()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +88,91 @@
|
|||||||
el.type = 'password';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
@@ -82,8 +181,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
Manage runtime configuration such as SMTP settings and OAuth parameters.
|
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||||
These secrets are stored securely in OpenBao Vault.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -93,9 +194,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
||||||
<div class="col-md-6 mb-4">
|
<li class="nav-item" role="presentation">
|
||||||
<div class="card shadow-sm border-0 h-100">
|
<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">
|
<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>
|
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,9 +235,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="smtp-pass">
|
<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>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
</div>
|
</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 class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">From Address</label>
|
<label class="form-label">From Address</label>
|
||||||
@@ -131,8 +252,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-4">
|
<!-- OAuth Tab -->
|
||||||
<div class="card shadow-sm border-0 h-100">
|
<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">
|
<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>
|
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,9 +266,10 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">JWT Secret</label>
|
<label class="form-label">JWT Secret</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="oauth-jwtsecret">
|
<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>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||||
</div>
|
</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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||||
@@ -159,6 +282,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,9 @@
|
|||||||
const isManaged = !!(r.metadata && r.metadata.managed);
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
if(managedFilter === 'managed' && !isManaged) return false;
|
if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
if(managedFilter === 'unmanaged' && isManaged) return false;
|
if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
|
||||||
|
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
if(!isAuto) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,50 +162,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Terms of Service ──────────────────────────────────────────────────
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
toggleFilterInputs();
|
toggleFilterInputs();
|
||||||
loadTos();
|
|
||||||
loadMetrics();
|
loadMetrics();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -385,30 +345,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOS Card -->
|
|
||||||
<div class="card shadow mb-5">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
|
|
||||||
<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 shadow-sm" id="tos-content" rows="12"></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 shadow-sm" onclick="saveTos()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
<div id="tos-result" style="display:none" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actionable Metrics Card -->
|
<!-- Actionable Metrics Card -->
|
||||||
<div class="card shadow mb-5">
|
<div class="card shadow mb-5">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
@@ -128,13 +128,19 @@
|
|||||||
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
|
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
|
||||||
// the field ids so the New and Edit modals don't collide. `values` (optional)
|
// the field ids so the New and Edit modals don't collide. `values` (optional)
|
||||||
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
|
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
|
||||||
function configFormHtml(type, prefix, values) {
|
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
|
||||||
|
// secret fields are never shown there (secrets have their own modal); the New
|
||||||
|
// modal passes true so initial secrets can be set at create time.
|
||||||
|
function configFormHtml(type, prefix, values, includeSecrets) {
|
||||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||||
|
if (includeSecrets === undefined) includeSecrets = true;
|
||||||
var v = values || {};
|
var v = values || {};
|
||||||
var html = '';
|
var html = '';
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
|
if (!includeSecrets && f.secret) return;
|
||||||
var val = v[f.key];
|
var val = v[f.key];
|
||||||
|
if (f.secret) val = '';
|
||||||
if (val === undefined || val === null) val = '';
|
if (val === undefined || val === null) val = '';
|
||||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
var req = f.required ? ' required' : '';
|
var req = f.required ? ' required' : '';
|
||||||
@@ -149,6 +155,53 @@
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
|
||||||
|
// The stored value is always a 5-field cron string. A `<select>` picks a
|
||||||
|
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
|
||||||
|
// element ids (np-/ed-) so the two modals don't collide.
|
||||||
|
var CRON_PRESETS = [
|
||||||
|
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||||
|
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||||
|
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||||
|
{ key: 'custom', label: 'Custom…', cron: null },
|
||||||
|
];
|
||||||
|
function cronKeyFor(cron) {
|
||||||
|
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||||
|
return m ? m.key : 'custom';
|
||||||
|
}
|
||||||
|
function cronSelectHtml(prefix, current) {
|
||||||
|
current = current || '0 * * * *';
|
||||||
|
var key = cronKeyFor(current);
|
||||||
|
var opts = CRON_PRESETS.map(function(p){
|
||||||
|
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
|
||||||
|
}).join('');
|
||||||
|
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
|
||||||
|
var rawVal = key === 'custom' ? current : current;
|
||||||
|
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
|
||||||
|
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
|
||||||
|
}
|
||||||
|
function onCronChange(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
if (!sel || !raw) return;
|
||||||
|
if (sel.value === 'custom') {
|
||||||
|
raw.style.display = '';
|
||||||
|
} else {
|
||||||
|
raw.style.display = 'none';
|
||||||
|
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) raw.value = preset.cron;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cronFromForm(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
if (sel && sel.value !== 'custom') {
|
||||||
|
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) return preset.cron;
|
||||||
|
}
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
return (raw && raw.value.trim()) || '0 * * * *';
|
||||||
|
}
|
||||||
|
|
||||||
// Collect a flat {field: value} object from the rendered config form.
|
// Collect a flat {field: value} object from the rendered config form.
|
||||||
function collectConfig(type, prefix) {
|
function collectConfig(type, prefix) {
|
||||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||||
@@ -179,10 +232,9 @@
|
|||||||
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
|
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
||||||
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
|
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Slug <span class="text-danger">*</span></label>' +
|
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' +
|
cronSelectHtml('np-', '0 * * * *') +
|
||||||
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
|
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></div>' +
|
|
||||||
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
|
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
|
||||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
|
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
|
||||||
});
|
});
|
||||||
@@ -197,13 +249,11 @@
|
|||||||
var type = document.getElementById('np-type').value;
|
var type = document.getElementById('np-type').value;
|
||||||
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
||||||
var name = document.getElementById('np-name').value.trim();
|
var name = document.getElementById('np-name').value.trim();
|
||||||
var slug = document.getElementById('np-slug').value.trim();
|
var cron = cronFromForm('np-');
|
||||||
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
|
|
||||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
|
|
||||||
var config = collectConfig(type, 'np-');
|
var config = collectConfig(type, 'np-');
|
||||||
try {
|
try {
|
||||||
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config });
|
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
app.messages.toast('Plugin created and scheduled.', 'success');
|
app.messages.toast('Plugin created and scheduled.', 'success');
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
@@ -224,9 +274,10 @@
|
|||||||
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></div>' +
|
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
|
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
|
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
|
||||||
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
|
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||||
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' +
|
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
|
||||||
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</div>',
|
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
|
||||||
|
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
|
||||||
footer: {
|
footer: {
|
||||||
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
||||||
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
|
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
|
||||||
@@ -238,7 +289,7 @@
|
|||||||
var p = pluginsById[id];
|
var p = pluginsById[id];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
var name = document.getElementById('ed-name').value.trim();
|
var name = document.getElementById('ed-name').value.trim();
|
||||||
var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *';
|
var cron = cronFromForm('ed-');
|
||||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||||
var config = collectConfig(p.pluginType, 'ed-');
|
var config = collectConfig(p.pluginType, 'ed-');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||||
|
$('#profile-uid-header').text(user.uid);
|
||||||
$.scope.user.update(user);
|
$.scope.user.update(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +242,7 @@
|
|||||||
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="fa-regular fa-id-card"></i>
|
<i class="fa-regular fa-id-card"></i>
|
||||||
Profile: <strong>{{user.uid}}</strong>
|
Profile: <strong id="profile-uid-header"></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
||||||
@@ -278,7 +279,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
||||||
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
|
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -329,27 +330,27 @@
|
|||||||
<p class="text-muted small mb-0">
|
<p class="text-muted small mb-0">
|
||||||
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 border-top pt-3">
|
<div class="mt-3 border-top pt-3">
|
||||||
<h6 class="text-muted">Admin Actions</h6>
|
<h6 class="text-muted">Admin Actions</h6>
|
||||||
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
||||||
{{#isActive}}
|
{{#isActive}}
|
||||||
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
||||||
<i class="fa-solid fa-lock"></i> Deactivate
|
<i class="fa-solid fa-lock"></i> Deactivate
|
||||||
</button>
|
</button>
|
||||||
{{/isActive}}
|
{{/isActive}}
|
||||||
{{#isInactive}}
|
{{#isInactive}}
|
||||||
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
||||||
<i class="fa-solid fa-lock-open"></i> Activate
|
<i class="fa-solid fa-lock-open"></i> Activate
|
||||||
</button>
|
</button>
|
||||||
{{/isInactive}}
|
{{/isInactive}}
|
||||||
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
||||||
<i class="fa-solid fa-user-secret"></i> Impersonate
|
<i class="fa-solid fa-user-secret"></i> Impersonate
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||||
<i class="fa-solid fa-user-slash"></i> Delete User
|
<i class="fa-solid fa-user-slash"></i> Delete User
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||