Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d9ce1078 | |||
| 181ca8c9cb | |||
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a | |||
| 87339da1b2 | |||
| 49100c9b68 | |||
| e8d04203c3 | |||
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 | |||
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 | |||
| 7a364bfb8e | |||
| b7aac2d2ba | |||
| 4945dec9c2 | |||
| 59d68c0269 | |||
| ef2207ed72 |
@@ -19,6 +19,9 @@
|
|||||||
!API.md
|
!API.md
|
||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
|
||||||
|
# in Dockerfile.openldap needs these present in the build context.
|
||||||
|
!docs/images/**
|
||||||
|
|
||||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
# nodejs/tests/
|
# nodejs/tests/
|
||||||
|
|||||||
@@ -1,3 +1,224 @@
|
|||||||
|
# v1.31.0 - 2026-08-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances.
|
||||||
|
- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`).
|
||||||
|
- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (8–128 chars) populating input fields with security notices.
|
||||||
|
- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`).
|
||||||
|
- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners.
|
||||||
|
- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template.
|
||||||
|
|
||||||
|
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/v1/ldap/bind` and `POST /api/v1/ldap/search`** — an LDAP-over-HTTPS
|
||||||
|
API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call
|
||||||
|
to the SSO, which performs the real bind/search against its own OpenLDAP. This
|
||||||
|
kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a
|
||||||
|
Bearer token: an agent token or a self-service API token (PAT). `/search` is
|
||||||
|
restricted to agent callers (the SSSD user/group-resolution use case) and runs
|
||||||
|
under the admin bind — see DESIGN.md §9.5 for the scoped-service-account
|
||||||
|
follow-up.
|
||||||
|
- **LDAP byte-pump relay** (`utils/ldap_tunnel.js`) — the SSO relays raw LDAP
|
||||||
|
bytes from an agent's local socket into its real OpenLDAP and pipes the
|
||||||
|
response back, over the existing agent WSS channel (`ldap_tunnel` messages).
|
||||||
|
The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4.
|
||||||
|
- **`POST /api/v1/agent/secrets`** — an agent fetches its own node-scoped OpenBao
|
||||||
|
secrets (DESIGN.md §5). The agent may only read under `secret/data/nodes/<id>/*`;
|
||||||
|
the SSO fetches with its own OpenBao access, so the agent never holds a Vault
|
||||||
|
token. Agent-token authed (not admin-gated).
|
||||||
|
- **`iam_apply` command** — the SSO pushes node-scoped IAM config (sudo rules,
|
||||||
|
SSH keys, access control, revocation) to an agent as a signed high-risk
|
||||||
|
command (DESIGN.md §6). Added to `HIGH_RISK_COMMANDS`.
|
||||||
|
- **Agent capabilities in the Directory UI** — the agent reports its enabled
|
||||||
|
capabilities in its `discovery` frame; the SSO stores them and the host's
|
||||||
|
Metrics tab renders them as green/gray badges, so an operator can see at a
|
||||||
|
glance what each agent is allowed to do.
|
||||||
|
- **`GET /api/agent/join-keys/:id/agents`** — which hosts enrolled through a
|
||||||
|
given join key. Matches on the trace `Agent.enroll` already leaves in
|
||||||
|
`description` ("Self-enrolled with join key `<prefix>`") rather than a stored
|
||||||
|
relation.
|
||||||
|
- **Join key management in the Install Agent modal** — a table (label, prefix,
|
||||||
|
created date, hosts joined, status) alongside the existing mint/select
|
||||||
|
dropdown, with **Revoke** and **Delete** actions and a click-through to see
|
||||||
|
which hosts joined via a given key. Previously these were API-only. Revoke
|
||||||
|
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
|
||||||
|
blocking native `confirm()` (freezes the whole tab) or the shared
|
||||||
|
`app.messages.confirm()` banner (a single `.actionMessage` shared by the
|
||||||
|
whole card, so a second click before the first resolves leaves a dangling
|
||||||
|
`$('body').one('click', ...)` handler from the first call and desyncs which
|
||||||
|
row the banner is actually confirming for).
|
||||||
|
|
||||||
|
# v1.30.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
|
||||||
|
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
|
||||||
|
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
|
||||||
|
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
|
||||||
|
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
|
||||||
|
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
|
||||||
|
|
||||||
|
# v1.30.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
|
||||||
|
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
|
||||||
|
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||||
|
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
|
||||||
|
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
|
||||||
|
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
|
||||||
|
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
|
||||||
|
|
||||||
|
# v1.30.0
|
||||||
|
|
||||||
|
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||||
|
|
||||||
|
### theta-agent — enrollment without pre-registering
|
||||||
|
|
||||||
|
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
|
||||||
|
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
|
||||||
|
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
|
||||||
|
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
|
||||||
|
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
|
||||||
|
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
|
||||||
|
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
|
||||||
|
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
|
||||||
|
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
|
||||||
|
|
||||||
|
# v1.29.0
|
||||||
|
|
||||||
|
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
||||||
|
|
||||||
|
### Security — theta-agent channel
|
||||||
|
|
||||||
|
- **sec: `/api/agent/ws` accepted any token.** There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token it guessed. Tokens were generated in the *browser* (`generateRandomHexToken`) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with `4001` and audited.
|
||||||
|
- **sec: the command signing key was ephemeral.** `AgentManager` generated an Ed25519 pair in its constructor, so it changed on every process start and the `public_key` an agent pinned in `agent.yml` stopped matching immediately. The key now lives in OpenBao at `secret/agent/signing-key` and survives restarts. If it cannot be loaded the SSO **refuses** to send high-risk commands rather than signing with a key no agent has seen (`signingAvailable: false` on `GET /api/agent/nodes`).
|
||||||
|
- **sec: commands are addressed by agent id, not token.** A credential has no business in a URL, an access log or browser history.
|
||||||
|
- **sec: agent actions are audited.** Enroll, update, rotate, revoke, delete, every command (with `signed`), and every rejected connection are emitted as structured `"component":"agent"` log records carrying the acting user.
|
||||||
|
|
||||||
|
### theta-agent — enrollment & resource binding
|
||||||
|
|
||||||
|
- feat: `POST /api/agent/enroll` mints the token server-side and returns it **once**; only its SHA-256 is stored. Plus `PUT /nodes/:id` (rename/rebind), `POST /nodes/:id/rotate`, `POST /nodes/:id/revoke`, `DELETE /nodes/:id`. Rotate, revoke and delete drop the live socket immediately (`4004`/`4003`) instead of waiting for a reconnect.
|
||||||
|
- feat: an agent binds to a **host resource** (`resourceId`). The Directory reads that link instead of guessing by hostname — the old `agentsByHost[name]` match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one.
|
||||||
|
- feat: **agent discovery reaches the Directory.** A bound agent's facts (`os`, `kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`) are written onto its host resource, tagged `discovery_sources: ["theta-agent"]` with an `agentId` back-reference. An unbound agent goes through the normal reconciler. Previously `handleDiscovery` wrote to an in-memory record and updated nothing — the one source actually running *on* the host contributed nothing to the directory.
|
||||||
|
- feat: agent state is persisted, so an agent that is installed but **offline** is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
|
||||||
|
- feat: the Install Agent modal enrolls first and builds the install command from the result, including `--public-key`. `public_key` was never emitted into the generated `agent.yml` before, so no installed agent could verify anything.
|
||||||
|
- fix: `registerAgent` is synchronous. Awaiting a database write before attaching the WebSocket `message` listener lost every agent's first `discovery` frame, which it sends the instant the socket opens (`ws` drops events emitted with no listener attached).
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- feat: **the resource tree is collapsible.** Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
|
||||||
|
- fix: **the Proxmox plugin mismatched MAC addresses to IPs.** It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
|
||||||
|
- feat: Proxmox discovery emits an **endpoint resource** (named from `/cluster/status`) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent.
|
||||||
|
- feat: discovered guests carry `sourceId` (`<node>/qemu/<vmid>`), `node`, `vmid` and `macAddress`, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources and `sourceId` to 54/54.
|
||||||
|
- fix: Proxmox interfaces belonging to something running *inside* a guest (`docker0`, `veth*`, `br-*`, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches.
|
||||||
|
- fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox **nodes** report their own IP/MAC (recovered from `enx<mac>` predictable names, since `/nodes/*/network` carries no `hwaddr`). Offline nodes are recorded with `status` instead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week.
|
||||||
|
- fix: **the reconciler could make a resource its own parent.** Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
|
||||||
|
- fix: **hosts were named after their MAC address.** `bestName` preferred the *longer* name, so UniFi's `ac:16:2d:b3:da:80` (17 chars) beat Proxmox's real hostname `dl380-0` (7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank.
|
||||||
|
- fix: `isIp` never matched anything — `\\.` inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered.
|
||||||
|
- fix: a discovered device can only merge into a resource of the same kind. A VM named `gitea-runner` could match a hand-created *service* of the same name on the name rule and overwrite it.
|
||||||
|
- perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
|
||||||
|
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read `metadata.ip` while any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and shows `vmid`, slug, `sourceId` and per-interface MAC/name.
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's `.container`.
|
||||||
|
|
||||||
|
### Build & docs
|
||||||
|
|
||||||
|
- fix: `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29.
|
||||||
|
- docs: `docs/agents.md` rewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a corrected `public_key` example (the documented `MCowBQYDK2VwAyEA...` was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32).
|
||||||
|
- docs: `docs/directory.md` covers the collapsible tree and the corrected seed hierarchy; `docs/plugins.md` documents what the Proxmox plugin produces and why the endpoint has no IP.
|
||||||
|
|
||||||
|
# v1.28.0
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
|
||||||
|
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
|
||||||
|
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
|
||||||
|
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
|
||||||
|
|
||||||
|
# v1.27.0
|
||||||
|
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
|
||||||
|
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
|
||||||
|
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
|
||||||
|
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
|
||||||
|
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
|
||||||
|
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
|
||||||
|
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
|
||||||
|
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
|
||||||
|
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
|
||||||
|
|
||||||
|
# v1.26.1
|
||||||
|
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
||||||
|
|
||||||
|
# v1.26.0
|
||||||
|
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
|
||||||
|
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
|
||||||
|
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
|
||||||
|
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
|
||||||
|
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
|
||||||
|
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
|
||||||
|
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
|
||||||
|
|
||||||
|
# v1.25.0
|
||||||
|
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
||||||
|
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
||||||
|
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
|
||||||
|
|
||||||
|
# v1.24.0
|
||||||
|
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
|
||||||
|
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
|
||||||
|
- feat: Directory resource slug is now read-only and derived from the name
|
||||||
|
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
|
||||||
|
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
|
||||||
|
|
||||||
|
# v1.23.0
|
||||||
|
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
|
||||||
|
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
|
||||||
|
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
|
||||||
|
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
|
||||||
|
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
|
||||||
|
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
|
||||||
|
|
||||||
|
# v1.22.0
|
||||||
|
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
|
||||||
|
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
|
||||||
|
|
||||||
|
# v1.21.0
|
||||||
|
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
|
||||||
|
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
|
||||||
|
- feat: shared-secrets API + Shared tab in the vault UI
|
||||||
|
|
||||||
|
# v1.20.0
|
||||||
|
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
|
||||||
|
|
||||||
## v1.19.0
|
## v1.19.0
|
||||||
- Added WebSocket endpoint for theta-agent C2
|
- Added WebSocket endpoint for theta-agent C2
|
||||||
|
|
||||||
@@ -644,3 +865,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
||||||
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
|
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
|
||||||
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
|
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
## [1.19.6] - 2026-08-02
|
||||||
|
### Fixed
|
||||||
|
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
# 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.
|
||||||
@@ -184,6 +184,11 @@ COPY README.md /README.md
|
|||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.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
|
||||||
|
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||||
|
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||||
|
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||||
|
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||||
|
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
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ COPY nodejs/conf ./conf
|
|||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
|
# Without this the discovery/plugin suites cannot even load their subject and
|
||||||
|
# fail as "Cannot find module ../plugins/discovery/..." -- plugin code was
|
||||||
|
# effectively untested in CI.
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
COPY nodejs/routes ./routes
|
COPY nodejs/routes ./routes
|
||||||
COPY nodejs/services ./services
|
COPY nodejs/services ./services
|
||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
@@ -43,6 +47,8 @@ COPY directory_spec.md /directory_spec.md
|
|||||||
COPY test_seed.js ./test_seed.js
|
COPY test_seed.js ./test_seed.js
|
||||||
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
||||||
RUN chmod +x /usr/local/bin/seed-test-user
|
RUN chmod +x /usr/local/bin/seed-test-user
|
||||||
|
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
|
||||||
|
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
|
||||||
|
|
||||||
# Default command: seed the test user, then run the test suite
|
# Default command: seed the test user, then run the test suite
|
||||||
CMD ["sh", "-c", "seed-test-user && npm test"]
|
CMD ["sh", "-c", "seed-test-user && npm test"]
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
|
|||||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
||||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
||||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
||||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
OAuth clients only), `app_sso_invite` (invitation management),
|
||||||
|
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
|
||||||
DEPLOYMENT.md for the full setup.
|
DEPLOYMENT.md for the full setup.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
||||||
|
#
|
||||||
|
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
|
||||||
|
# live), and a client that simulates the agent: it enrolls one, connects over
|
||||||
|
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
|
||||||
|
# OpenLDAP and pipes the response back.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
|
||||||
|
# # exit code 0 = tunnel works; the client prints E2E PASS.
|
||||||
|
|
||||||
|
services:
|
||||||
|
ldap:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=Test SSO
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
volumes:
|
||||||
|
- ldap-data:/var/lib/ldap
|
||||||
|
- ldap-certs:/etc/openldap/certs
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
sso:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["node", "bin/www"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- NODE_PORT=3001
|
||||||
|
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
|
||||||
|
# high-risk agent commands and read node-scoped secrets.
|
||||||
|
- VAULT_ADDR=http://theta-test-bao:8200
|
||||||
|
- VAULT_TOKEN=${VAULT_TOKEN:-}
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- SSO_URL=http://sso:3001
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
sso:
|
||||||
|
condition: service_started
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ldap-data:
|
||||||
|
ldap-certs:
|
||||||
@@ -355,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
#
|
||||||
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||||
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||||
|
# the theta-suite bootstrap puts the first admin person into it.
|
||||||
|
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||||
# hold those rights by membership rather than by a special case in app code.
|
# by membership rather than by a special case in app code. This is what makes
|
||||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||||
# anything binding LDAP directly -- instead of only to callers that happen
|
# LDAP directly -- instead of only to callers that happen to route through
|
||||||
# to route through utils/permission.js.
|
# utils/permission.js.
|
||||||
#
|
#
|
||||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
# non-person accounts, not a permission, and nesting admins into it would
|
# non-person accounts, not a permission, and nesting admins into it would
|
||||||
@@ -381,10 +385,10 @@ EOF
|
|||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
changetype: modify
|
changetype: modify
|
||||||
add: member
|
add: member
|
||||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
info "Nested app_super_admin into the SSO admin groups"
|
info "Nested god_admin into the SSO admin groups"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
|
||||||
|
|
||||||
|
{% seo title=false %}
|
||||||
|
<title>{% if page.title %}{{ page.title }} · {% 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>
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Theta Agent & Endpoint Management
|
||||||
|
nav_order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theta Agent & Endpoint Management
|
||||||
|
|
||||||
|
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2)
|
||||||
|
endpoint management daemon written in Go for Linux hosts across your home lab,
|
||||||
|
infrastructure, or data center. It connects outbound via a long-lived WebSocket
|
||||||
|
connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`),
|
||||||
|
enabling real-time host telemetry, automated host discovery, and local-first
|
||||||
|
administrative management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enrollment
|
||||||
|
|
||||||
|
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||||
|
not issue are rejected** at the WebSocket handshake.
|
||||||
|
|
||||||
|
There are two ways to get a host enrolled, and the first is the normal one.
|
||||||
|
|
||||||
|
### Join key — install the agent and the host appears
|
||||||
|
|
||||||
|
Hand the machine a **join key** and nothing else. On first connect the SSO
|
||||||
|
enrolls the host, issues it its own per-agent token plus the public key it must
|
||||||
|
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
|
||||||
|
key. From then on it authenticates as itself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --join-key "tjk_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole procedure — no pre-registering the machine, no copying a
|
||||||
|
public key by hand. `setup.sh` mints a key and configures the stack's own host
|
||||||
|
this way automatically.
|
||||||
|
|
||||||
|
The join key is a *bootstrap* credential, not the host's identity. That
|
||||||
|
distinction is what keeps one key convenient without making it a fleet-wide
|
||||||
|
skeleton key: every host still ends up individually revocable, and a compromised
|
||||||
|
host does not yield a credential that works anywhere else.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||||
|
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||||
|
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
|
||||||
|
|
||||||
|
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||||
|
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||||
|
|
||||||
|
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
|
||||||
|
only checks `revoked` and `expires_on`; it never invalidates the key itself.
|
||||||
|
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
|
||||||
|
working until you revoke or delete it (or it expires) — "one key works for as
|
||||||
|
many hosts as you like" above is literal, not a figure of speech.
|
||||||
|
|
||||||
|
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
|
||||||
|
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
|
||||||
|
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
|
||||||
|
Clicking a key's "N hosts" link expands the list of hosts that joined through
|
||||||
|
it (name, online status, joined date, last seen).
|
||||||
|
|
||||||
|
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
|
||||||
|
lines, and the hosts-joined list in the UI above is queryable directly:
|
||||||
|
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
|
||||||
|
`label`, and `keyPrefix`.
|
||||||
|
- Each enrollment through that key: `action: "join"` records `agentId`,
|
||||||
|
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
|
||||||
|
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
|
||||||
|
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
|
||||||
|
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
|
||||||
|
than a stored foreign key, since a join key is exchanged for a per-agent
|
||||||
|
token immediately and from then on the agent's own identity is what matters.
|
||||||
|
|
||||||
|
### Pre-registering a host
|
||||||
|
|
||||||
|
When you want the agent bound to a specific Directory host up front, enroll it
|
||||||
|
from **Directory → Install Agent**:
|
||||||
|
|
||||||
|
1. Give the agent a name and **bind it to a host resource**. The binding is what
|
||||||
|
links telemetry, status and commands to a Directory entry.
|
||||||
|
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
||||||
|
SHA-256, and shows the raw value **once**.
|
||||||
|
3. Copy the generated install command — it already carries the token and the
|
||||||
|
server's public key.
|
||||||
|
|
||||||
|
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
|
||||||
|
`PUT /api/agent/nodes/:id` or from the Directory.
|
||||||
|
|
||||||
|
Or via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://<SSO_HOST>/api/agent/enroll \
|
||||||
|
-H "Authorization: Bearer <admin-api-token>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains `token` (once only) and `publicKey`.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
|
||||||
|
| `POST /api/agent/enroll` | Mint an agent + token |
|
||||||
|
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
|
||||||
|
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
|
||||||
|
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
|
||||||
|
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
|
||||||
|
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
|
||||||
|
|
||||||
|
Revoke, rotate and delete **drop any live connection immediately** — they do not
|
||||||
|
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
|
||||||
|
token: a token is a credential and has no business in a URL or a log.
|
||||||
|
|
||||||
|
Enrollment, revocation, rotation, every command, and every rejected connection
|
||||||
|
are written to the application log as structured `"component":"agent"` records
|
||||||
|
with the acting user.
|
||||||
|
|
||||||
|
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
|
||||||
|
> the agent to issue a new one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Functionality
|
||||||
|
|
||||||
|
### 1. Host Discovery & Inventory
|
||||||
|
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
|
||||||
|
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
|
||||||
|
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
|
||||||
|
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
|
||||||
|
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
|
||||||
|
|
||||||
|
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
|
||||||
|
|
||||||
|
### 2. Real-Time Telemetry Streaming
|
||||||
|
Every 30 seconds, the agent streams real-time performance metrics:
|
||||||
|
- **CPU Load**: System-wide CPU utilization percentage.
|
||||||
|
- **Memory Utilization**: RAM usage percentage and available memory.
|
||||||
|
- **Disk Utilization**: Root filesystem usage percentage.
|
||||||
|
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
|
||||||
|
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Viewing in the SSO Manager
|
||||||
|
|
||||||
|
Agent status and telemetry live on the **Directory** page — there is no separate
|
||||||
|
Agents page. For each **host** resource that has a connected theta-agent, the
|
||||||
|
Directory shows a status dot in the row:
|
||||||
|
|
||||||
|
| Color | Meaning |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||||
|
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
||||||
|
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
|
||||||
|
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
|
||||||
|
|
||||||
|
Red and grey used to be the same colour, which made an ordinary directory of
|
||||||
|
hosts look like an outage. Because the enrollment now outlives the connection,
|
||||||
|
"installed but down" is distinguishable from "never had an agent".
|
||||||
|
|
||||||
|
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||||
|
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||||
|
|
||||||
|
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
|
||||||
|
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
|
||||||
|
without a binding fall back to matching their reported hostname against the
|
||||||
|
resource name — the old behaviour, kept only as a fallback, because it silently
|
||||||
|
failed whenever a Directory name differed from the machine's hostname and
|
||||||
|
aliased two hosts that happened to share one.
|
||||||
|
|
||||||
|
### Agent discovery feeds the Directory
|
||||||
|
|
||||||
|
A bound agent's discovery payload is written onto its host resource (`os`,
|
||||||
|
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
|
||||||
|
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
|
||||||
|
runs *on* the host it describes, so it is the most authoritative source the
|
||||||
|
directory has. An unbound agent goes through the normal discovery reconciler
|
||||||
|
instead, matching like any other source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-First Security & Capability Matrix
|
||||||
|
|
||||||
|
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
|
||||||
|
|
||||||
|
| Capability | Config Key | Risk Level | Description & Impact |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
|
||||||
|
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
|
||||||
|
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
|
||||||
|
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
|
||||||
|
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
|
||||||
|
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
|
||||||
|
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
|
||||||
|
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Risk Command Verification (Protocol v1.2.0)
|
||||||
|
|
||||||
|
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
|
||||||
|
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
|
||||||
|
no HTML escaping, `signature` omitted).
|
||||||
|
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||||
|
3. The Base64 signature is appended to the message payload.
|
||||||
|
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
|
||||||
|
|
||||||
|
**The signing key is persistent.** It lives in OpenBao at
|
||||||
|
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
|
||||||
|
`agent.yml` keeps matching. (It used to be generated in memory at boot and
|
||||||
|
changed on every restart, which made pinning impossible.) If the SSO cannot load
|
||||||
|
or store a key it **refuses** to send high-risk commands rather than signing with
|
||||||
|
one no agent has seen — `GET /api/agent/nodes` reports this as
|
||||||
|
`signingAvailable: false`.
|
||||||
|
|
||||||
|
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
|
||||||
|
`./setup.sh` from theta-suite if you are upgrading.
|
||||||
|
|
||||||
|
**Verification is fail-closed on the agent.** An agent with no `public_key`
|
||||||
|
configured rejects every high-risk command. Earlier versions logged "skipping
|
||||||
|
signature verification" and executed them, so an agent installed without a key
|
||||||
|
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Engine — rendering OpenBao secrets to local files
|
||||||
|
|
||||||
|
The agent can render OpenBao secrets to local files that any process on the
|
||||||
|
host — a bash script, a systemd unit, a Node app, whatever — reads like an
|
||||||
|
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
|
||||||
|
values over its existing WSS channel, and the SSO fetches them from OpenBao
|
||||||
|
using its own access, scoped so the agent can only ever read its own node's
|
||||||
|
secrets.
|
||||||
|
|
||||||
|
**Node scope.** Every path an agent can request must start with
|
||||||
|
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
|
||||||
|
(`POST /api/v1/agent/secrets`); a request for any other node's path is
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
|
||||||
|
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
|
||||||
|
```
|
||||||
|
|
||||||
|
A compromised agent can therefore never reach another host's secrets, or
|
||||||
|
anything outside `secret/data/nodes/*`.
|
||||||
|
|
||||||
|
### Walkthrough: a 3rd-party app reads a secret the agent rendered
|
||||||
|
|
||||||
|
This walks through the whole path end to end, on a stack freshly brought up
|
||||||
|
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
|
||||||
|
any theta-suite install.
|
||||||
|
|
||||||
|
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
|
||||||
|
install command on the target host as root.
|
||||||
|
|
||||||
|
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
|
||||||
|
|
||||||
|
On first connect the agent exchanges the join key for its own token + the
|
||||||
|
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
|
||||||
|
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
|
||||||
|
the next step.
|
||||||
|
|
||||||
|
**2. Turn on the `secrets` capability and point it at a template.** Add to the
|
||||||
|
host's `/etc/theta42/agent.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secrets:
|
||||||
|
- template: /etc/theta/templates/db.env.tpl
|
||||||
|
target: /etc/theta/rendered/db.env
|
||||||
|
reload: "" # optional: e.g. "systemctl reload myapp"
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
secrets: true
|
||||||
|
```
|
||||||
|
|
||||||
|
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
|
||||||
|
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
|
||||||
|
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the agent to pick up the config change.
|
||||||
|
|
||||||
|
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
|
||||||
|
|
||||||
|
```
|
||||||
|
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
|
||||||
|
username=demoapp password=CorrectHorseBattery42
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
|
||||||
|
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
|
||||||
|
node-scope check above). It's idempotent: it skips silently if that path is
|
||||||
|
already seeded.
|
||||||
|
|
||||||
|
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
|
||||||
|
— push it the same way any admin command goes out, `POST
|
||||||
|
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
|
||||||
|
automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
|
||||||
|
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"command": "render_secrets", "payload": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent logs `Received command: render_secrets` / `Rendering secret
|
||||||
|
templates...` and atomically writes the target file at mode `0600`:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat /etc/theta/rendered/db.env
|
||||||
|
DB_USER="demoapp"
|
||||||
|
DB_PASS="CorrectHorseBattery42"
|
||||||
|
```
|
||||||
|
|
||||||
|
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
|
||||||
|
among the reported capabilities:
|
||||||
|
|
||||||
|
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
|
||||||
|
|
||||||
|
**5. Read it from a bash app on the same host.** The rendered file is just an
|
||||||
|
env file — no agent involvement needed to consume it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
. /etc/theta/rendered/db.env
|
||||||
|
echo "DB_USER=$DB_USER"
|
||||||
|
echo "DB_PASS=$DB_PASS"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Read it from a Node app on the same host:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const fs = require('fs');
|
||||||
|
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
|
||||||
|
const db = {};
|
||||||
|
for (const line of env.split('\n')) {
|
||||||
|
const m = /^(\w+)="(.*)"$/.exec(line.trim());
|
||||||
|
if (m) db[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
console.log('DB_USER=' + db.DB_USER);
|
||||||
|
console.log('DB_PASS=' + db.DB_PASS);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both print the same values the template resolved — `demoapp` /
|
||||||
|
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
|
||||||
|
theta-agent repo has these two scripts ready to run.
|
||||||
|
|
||||||
|
### Alternative: calling the API directly
|
||||||
|
|
||||||
|
Rendering to a file is the normal path — it works for any app regardless of
|
||||||
|
language, and the secret never touches an HTTP client the app itself controls.
|
||||||
|
But an app can also fetch its node's secrets directly, bypassing the template
|
||||||
|
engine entirely (useful for debugging, or a process that wants to hold the
|
||||||
|
value only in memory). This uses the **agent's own bearer token**, not an admin
|
||||||
|
token — the same node-scope enforcement applies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
|
||||||
|
fetch('https://sso.example.com/api/v1/agent/secrets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
|
||||||
|
}).then(r => r.json()).then(d => console.log(d.secrets));
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"secrets": {
|
||||||
|
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
|
||||||
|
"username": "demoapp",
|
||||||
|
"password": "CorrectHorseBattery42"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Deployment
|
||||||
|
|
||||||
|
### Quick One-Liner Install
|
||||||
|
Run the following command as `root` on the target Linux host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both values come from enrollment. The **Install Agent** modal builds this line
|
||||||
|
for you with them already filled in. Omitting `--public-key` leaves the agent
|
||||||
|
able to report telemetry but unable to accept any high-risk command.
|
||||||
|
|
||||||
|
### Custom Config Wizard
|
||||||
|
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration File Example (`/etc/theta42/agent.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/theta42/agent.yml
|
||||||
|
server_url: "wss://sso.example.com"
|
||||||
|
# Issued by the SSO. Left empty when installing with a join key -- the agent
|
||||||
|
# fills it in itself once the server enrolls it.
|
||||||
|
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||||
|
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
|
||||||
|
# agent once it has its own token.
|
||||||
|
join_key: ""
|
||||||
|
location: "dc-01-rack-12"
|
||||||
|
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
||||||
|
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
||||||
|
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
|
||||||
|
# command if this is wrong.
|
||||||
|
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
reboot: false
|
||||||
|
service_control: ["nginx", "docker", "sssd"]
|
||||||
|
arbitrary_bash: false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent is rejected (`close 4001`)
|
||||||
|
|
||||||
|
If the agent logs that the server rejected its token, the enrollment — not the
|
||||||
|
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
|
||||||
|
with an application code:
|
||||||
|
|
||||||
|
| Code | Meaning | Fix |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
|
||||||
|
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
|
||||||
|
| `4003` | Enrollment revoked or deleted | Re-enroll |
|
||||||
|
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
|
||||||
|
|
||||||
|
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
|
||||||
|
every 5 seconds — a credential that is wrong will not fix itself, and hammering
|
||||||
|
the SSO only floods its audit log.
|
||||||
|
|
||||||
|
An agent installed before protocol v1.2.0 carries a token generated in the
|
||||||
|
browser that the server never recorded, so it will be rejected with `4001` until
|
||||||
|
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
|
||||||
|
`join_key` and blank `auth_token` — it will re-enroll itself on the next
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
|
||||||
|
|
||||||
|
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
|
||||||
|
connecting to `wss://<sso-host>/api/agent/ws`, the WebSocket path is usually
|
||||||
|
fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind
|
||||||
|
the same NAT that owns the SSO often cannot reach its own **public IP** (no
|
||||||
|
hairpin/loopback NAT on many home routers), so the TCP dial times out even
|
||||||
|
though the same address works from outside.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. Point `agent.yml` `server_url` at an address the host can reach directly —
|
||||||
|
e.g. the SSO host's LAN IP (`http://<lan-ip>` or `http://<lan-ip>:3001` for a
|
||||||
|
no-TLS direct path).
|
||||||
|
2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach
|
||||||
|
their own public IP:443.
|
||||||
|
3. Add a local route/firewall rule on the agent host for its public IP.
|
||||||
|
|
||||||
|
> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make
|
||||||
|
> sure the proxy has a **persistent Host record** for the real SSO domain — not
|
||||||
|
> just the `localtest.me` placeholder — so routing survives a proxy restart
|
||||||
|
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
||||||
|
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
||||||
|
generic Jekyll theme. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f4f5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand img {
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown content typography, scoped to the card body so it doesn't leak
|
||||||
|
into the nav/footer. */
|
||||||
|
.site-content h1:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h1,
|
||||||
|
.site-content h2,
|
||||||
|
.site-content h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h2 {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h3 {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a {
|
||||||
|
color: #a3671f;
|
||||||
|
text-decoration-color: rgba(163, 103, 31, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a:hover {
|
||||||
|
color: #8a5a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre {
|
||||||
|
background-color: #212529;
|
||||||
|
color: #f8f9fa;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content code {
|
||||||
|
color: #a3671f;
|
||||||
|
background-color: #f4f0e8;
|
||||||
|
padding: .15em .4em;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre code {
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th,
|
||||||
|
.site-content table td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content blockquote {
|
||||||
|
border-left: 4px solid #C59341;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
background-color: #f8f6f1;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
||||||
|
two-up desktop layout -- stack them on narrow screens instead of
|
||||||
|
squeezing to illegibility. */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.site-content img[width] {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content hr {
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C59341" />
|
||||||
|
<stop offset="20%" stop-color="#E4B869" />
|
||||||
|
<stop offset="40%" stop-color="#FBF0B9" />
|
||||||
|
<stop offset="60%" stop-color="#DFB260" />
|
||||||
|
<stop offset="80%" stop-color="#BC8837" />
|
||||||
|
<stop offset="100%" stop-color="#A36F28" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" />
|
||||||
|
<stop offset="40%" stop-color="#F5E3B5" />
|
||||||
|
<stop offset="70%" stop-color="#D4A343" />
|
||||||
|
<stop offset="100%" stop-color="#8A5A16" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g filter="url(#drop-shadow)">
|
||||||
|
<g fill="url(#gold-grad)">
|
||||||
|
<path d="M 200,40
|
||||||
|
C 290,40 350,110 350,200
|
||||||
|
C 350,290 290,360 200,360
|
||||||
|
C 110,360 50,290 50,200
|
||||||
|
C 50,110 110,40 200,40 Z
|
||||||
|
M 200,75
|
||||||
|
C 130,75 88,130 88,200
|
||||||
|
C 88,270 130,325 200,325
|
||||||
|
C 270,325 312,270 312,200
|
||||||
|
C 312,130 270,75 200,75 Z"
|
||||||
|
fill-rule="evenodd" />
|
||||||
|
|
||||||
|
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||||
|
|
||||||
|
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="200" y="222"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="78"
|
||||||
|
font-weight="900"
|
||||||
|
fill="url(#text-grad)"
|
||||||
|
text-anchor="middle"
|
||||||
|
letter-spacing="-2">42</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Accounts, Groups & Managers
|
||||||
|
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accounts, Groups & Managers
|
||||||
|
|
||||||
|
This page explains the concepts behind the Users and Groups pages in plain
|
||||||
|
language. If you want the technical schema/attribute-level detail instead,
|
||||||
|
see the [LDAP reference](ldap.html).
|
||||||
|
|
||||||
|
## What's an account?
|
||||||
|
|
||||||
|
Every person (or app) that can sign in through this SSO Manager has an
|
||||||
|
**account** — a username, a display name, maybe an email address, and a
|
||||||
|
password (or, for service accounts, no password at all — see below).
|
||||||
|
Accounts live in the directory this app manages, and any other app you've
|
||||||
|
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
|
||||||
|
same accounts instead of keeping its own separate list of users and
|
||||||
|
passwords.
|
||||||
|
|
||||||
|
## Two kinds of account: people and service accounts
|
||||||
|
|
||||||
|
Most accounts belong to an actual person — check **Users → People** to see
|
||||||
|
them. But sometimes you need an account for something that *isn't* a
|
||||||
|
person: a media server, a backup script, a bind account another app uses to
|
||||||
|
look people up. These are **service accounts**, listed separately under
|
||||||
|
**Users → Service Accounts**, and they're different from a person's account
|
||||||
|
in two ways that matter:
|
||||||
|
|
||||||
|
- **No email required.** A service account doesn't need a mailbox, so the
|
||||||
|
form doesn't ask for one.
|
||||||
|
- **A password is optional.** If you leave it blank, nobody can log in as
|
||||||
|
that account — which is exactly what you want for something that only
|
||||||
|
ever gets used programmatically (a script authenticating with an API
|
||||||
|
token, or another app binding with a fixed, separately-configured
|
||||||
|
password you set yourself). Only give it a password if the account
|
||||||
|
genuinely needs to log in or bind somewhere as itself.
|
||||||
|
|
||||||
|
Aside from those two differences, a service account is a completely normal
|
||||||
|
account under the hood — it can belong to groups, have a manager, and so
|
||||||
|
on, just like anyone else's.
|
||||||
|
|
||||||
|
## Groups: who can do what
|
||||||
|
|
||||||
|
A **group** is just a named list of accounts, used to control access. This
|
||||||
|
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||||
|
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
|
||||||
|
pages at all), but you can also make your own groups for any app you
|
||||||
|
connect — say, a group listing everyone who should be allowed into your
|
||||||
|
photo server. Once a group exists, add or remove members from the
|
||||||
|
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||||
|
that group's name.
|
||||||
|
|
||||||
|
### Groups inside groups
|
||||||
|
|
||||||
|
A group can contain another group, not just people — the *Nested* tab on any
|
||||||
|
group card. Everyone in the inner group counts as a member of the outer one,
|
||||||
|
however many levels deep it goes.
|
||||||
|
|
||||||
|
This is mostly a way to stop repeating yourself. Make one `developers` group,
|
||||||
|
nest it into the handful of things developers should reach, and adding a new
|
||||||
|
developer to that one group grants all of them at once — instead of adding them
|
||||||
|
to each individually and slowly drifting out of sync. The app already does this
|
||||||
|
for itself: super admins are nested into every resource's admin group, and each
|
||||||
|
admin group into its access group, so "can administer it" always implies "can
|
||||||
|
use it".
|
||||||
|
|
||||||
|
Two things it won't let you do: put a group inside itself (directly or round a
|
||||||
|
longer loop), and empty a group completely — every group must keep at least one
|
||||||
|
member.
|
||||||
|
|
||||||
|
A note if you also manage the directory by hand: a group's member list shows
|
||||||
|
what is *directly* listed on it. Someone who gets in through a nested group is
|
||||||
|
a real member but won't appear there — the **Nested** tab shows what is nested,
|
||||||
|
and the API's `effective` view lists everyone who actually gets in.
|
||||||
|
|
||||||
|
## Every account's personal group
|
||||||
|
|
||||||
|
Separately from the groups above, every single account — person or
|
||||||
|
service account — automatically gets its own small, personal group when
|
||||||
|
it's created, named after the account itself. Most of the time you'll
|
||||||
|
never think about this; it exists so that, on a Linux system connected to
|
||||||
|
this directory, each account "owns" its own files by default the same way
|
||||||
|
a normal Unix user account would.
|
||||||
|
|
||||||
|
Occasionally you'll want to share that ownership with someone else — for
|
||||||
|
example, letting a second account also have write access to files a
|
||||||
|
service account owns. That's what the **"Members of `<uid>`'s group"**
|
||||||
|
section on a profile page is for: add another account there, and the
|
||||||
|
underlying Linux permissions treat them as if they belong to that same
|
||||||
|
personal group too.
|
||||||
|
|
||||||
|
## What's a "manager"?
|
||||||
|
|
||||||
|
Every account has one or more **managers** — the people allowed to edit
|
||||||
|
that account's profile (phone number, SSH key, home directory, and so on)
|
||||||
|
without needing full admin rights. By default, whoever created an account
|
||||||
|
(the admin who added it, or whoever sent the invite) becomes its first
|
||||||
|
manager, but you can add or remove managers later from the account's Edit
|
||||||
|
form.
|
||||||
|
|
||||||
|
This is useful for service accounts especially: if a service account
|
||||||
|
belongs to a particular project or person, make them its manager so they
|
||||||
|
can maintain it — rotate its SSH key, adjust its description — without
|
||||||
|
needing to be a full SSO administrator.
|
||||||
|
|
||||||
|
## Inviting someone vs. adding them yourself
|
||||||
|
|
||||||
|
From the Users page you can either fill in someone's details yourself
|
||||||
|
("Add new user"), or send them an **invite** — an email (or a link you copy
|
||||||
|
and send however you like) that lets them pick their own username and
|
||||||
|
password. Either way, the resulting account is identical; invites are just
|
||||||
|
a convenience so you don't have to know someone's preferred username or
|
||||||
|
handle their password directly.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page deliberately leaves out LDAP schema names, attribute types, and
|
||||||
|
protocol-level detail. If you're connecting a third-party app directly to
|
||||||
|
the LDAP directory, or you just want to know exactly what's stored where,
|
||||||
|
see the [LDAP reference](ldap.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: API Tokens
|
||||||
|
description: A plain-language guide to personal access tokens in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Tokens
|
||||||
|
|
||||||
|
This page explains what an API token is and when you'd want one. For the
|
||||||
|
full list of API endpoints a token can call, see the
|
||||||
|
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
|
## What's an API token, in plain terms?
|
||||||
|
|
||||||
|
Normally, you interact with this app by logging in through a web browser.
|
||||||
|
An **API token** (also called a personal access token, or PAT) is an
|
||||||
|
alternative way in — a long, random string that a script, a scheduled job,
|
||||||
|
or another program can use instead of a username and password, to act on
|
||||||
|
your behalf without a human typing a login in each time.
|
||||||
|
|
||||||
|
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
||||||
|
service using a "token" instead of your real password, this is the same
|
||||||
|
idea.
|
||||||
|
|
||||||
|
## When would you actually need one?
|
||||||
|
|
||||||
|
Most people never need to create one of these — you'll only want a token
|
||||||
|
if you're automating something, for example:
|
||||||
|
|
||||||
|
- A script that syncs users or groups from somewhere else into this SSO
|
||||||
|
Manager on a schedule.
|
||||||
|
- A backup or monitoring job that checks this app's health via its API.
|
||||||
|
- A CI/CD pipeline that needs to register or update an OAuth client
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
If you're not doing any of that, you don't need an API token — just log in
|
||||||
|
normally through the web UI.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Create a token from your Profile page, give it a name so you remember what
|
||||||
|
it's for later, and optionally an expiry. You'll be shown the token's
|
||||||
|
value **exactly once** — copy it somewhere safe immediately, because it
|
||||||
|
can't be viewed again afterward (only revoked or rotated). Whatever script
|
||||||
|
or tool you're using it with sends it along with each request, the same
|
||||||
|
way a browser sends your login session.
|
||||||
|
|
||||||
|
A token acts **as you**, with **your** permissions — if you're not an
|
||||||
|
admin, a token you create can't do admin-only things either. If you ever
|
||||||
|
suspect a token has leaked (ended up somewhere it shouldn't have, like a
|
||||||
|
public script or log file), revoke it immediately from your Profile page;
|
||||||
|
it stops working right away.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page doesn't attempt to list every API endpoint or show request/
|
||||||
|
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Connecting Apps (Single Sign-On)
|
||||||
|
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Connecting Apps (Single Sign-On)
|
||||||
|
|
||||||
|
This page explains, in plain language, what happens when you "connect" an
|
||||||
|
app to your SSO Manager so people can log into it with their existing
|
||||||
|
account. For the technical endpoint/token detail, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
## What does "single sign-on" actually mean?
|
||||||
|
|
||||||
|
Instead of every app you run having its own separate list of usernames and
|
||||||
|
passwords, they all check with this SSO Manager instead. You log in once,
|
||||||
|
here, and any connected app trusts that login — no separate password to
|
||||||
|
remember or manage for each one. If you ever need to lock someone out
|
||||||
|
everywhere at once, you do it in one place (deactivate their account here)
|
||||||
|
instead of hunting down every app individually.
|
||||||
|
|
||||||
|
The technology behind this is called **OAuth 2.0** and **OpenID Connect
|
||||||
|
(OIDC)** — you'll see both names used, often together, referring to the
|
||||||
|
same thing. You don't need to understand the protocol to use this page;
|
||||||
|
what matters practically is the handful of concepts below.
|
||||||
|
|
||||||
|
## What's a "client"?
|
||||||
|
|
||||||
|
Every app you connect is registered here as a **client** — a single entry
|
||||||
|
in the Directory representing that one app. Registering a client
|
||||||
|
gives you a **Client ID** and **Client Secret**: think of these like a
|
||||||
|
username and password, but for the *app itself* rather than for a person.
|
||||||
|
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||||
|
screen, along with the discovery URL shown at the top of this page, and
|
||||||
|
that app is now able to ask this SSO Manager to authenticate people on its
|
||||||
|
behalf.
|
||||||
|
|
||||||
|
**Treat the Client Secret like a password** — anyone who has it can
|
||||||
|
impersonate that app when talking to your SSO Manager. If you ever suspect
|
||||||
|
it's leaked, rotate it from the client's card.
|
||||||
|
|
||||||
|
## What are "scopes"?
|
||||||
|
|
||||||
|
**Scopes** control what information a connected app is allowed to ask for
|
||||||
|
about the person logging in — their username, email, group memberships,
|
||||||
|
and so on. Most apps tell you exactly which scopes they need in their own
|
||||||
|
setup instructions; when in doubt, the default set (`openid`, `profile`,
|
||||||
|
`email`, `groups`) covers what nearly every app expects.
|
||||||
|
|
||||||
|
## "Restrict to Groups"
|
||||||
|
|
||||||
|
By default, *any* account with an SSO Manager login can sign into a
|
||||||
|
connected app. If that's not what you want — say, a home automation
|
||||||
|
dashboard that only certain family members should reach — set **Restrict
|
||||||
|
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
||||||
|
Only members of that group will be allowed to log into that particular
|
||||||
|
app; everyone else gets turned away at the login step, even though their
|
||||||
|
SSO Manager account still works everywhere else.
|
||||||
|
|
||||||
|
## Redirect URIs
|
||||||
|
|
||||||
|
A **Redirect URI** is the exact web address the connected app wants people
|
||||||
|
sent back to once they've logged in here — it's a security measure so an
|
||||||
|
attacker can't trick the login flow into redirecting somewhere else. The
|
||||||
|
app's own setup instructions will tell you this value; copy it in exactly
|
||||||
|
as given. If the app is reachable via more than one hostname (for example,
|
||||||
|
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
|
||||||
|
this field supports wildcard patterns — see the inline help under the
|
||||||
|
field itself for the exact syntax.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page intentionally skips the protocol-level detail (exact endpoint
|
||||||
|
URLs, token formats, claim names). If you're troubleshooting a connection
|
||||||
|
or building something against the API directly, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
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 among these
|
||||||
|
four.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
|
||||||
|
|
||||||
|
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
|
||||||
|
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
|
||||||
|
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
|
||||||
|
everything else here. On top of that, the admin **Configuration** page in the
|
||||||
|
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
|
||||||
|
and applies the change to the live `conf` object immediately
|
||||||
|
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
|
||||||
|
If a value isn't behaving the way `conf/secrets.js` says it should, check the
|
||||||
|
Configuration UI / OpenBao before assuming a file edit didn't take — it's
|
||||||
|
almost certainly OpenBao (or a live UI edit) winning the merge.
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
---
|
||||||
|
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 nests your resources as a tree, making it easy
|
||||||
|
to comprehend your network topography at a glance. You can filter, search, and
|
||||||
|
sort your entire infrastructure inventory. Click the green `+` icon next to any
|
||||||
|
resource to add a child resource beneath it.
|
||||||
|
|
||||||
|
**Collapsing the tree.** Any resource with children carries a caret; click it to
|
||||||
|
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
|
||||||
|
everything at once. Collapsed state is remembered per browser, so the shape you
|
||||||
|
arrange survives a refresh (and the self-heal reload that follows most edits).
|
||||||
|
|
||||||
|
While a search filter is active every match is shown regardless of collapsed
|
||||||
|
ancestors — otherwise searching for something inside a folded subtree would
|
||||||
|
silently return nothing. Clearing the box restores your saved shape.
|
||||||
|
|
||||||
|
<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 **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
|
||||||
|
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
|
||||||
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
Services are parented to the host that actually runs them: Proxy and OpenResty
|
||||||
|
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
|
||||||
|
rest under the stack host. Installs seeded before this was fixed had all of them
|
||||||
|
under the stack host, leaving the two purpose-made host resources childless; the
|
||||||
|
seed re-parents those on its next run, and only when the current parent is the
|
||||||
|
one the old code set, so a layout you arranged deliberately is left alone.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery & Inventory
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery & Inventory
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The Directory holds two different kinds of thing, and the distinction matters
|
||||||
|
for every consumer of the directory:
|
||||||
|
|
||||||
|
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||||
|
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||||
|
groups, appear in the Catalog, and are the only hosts the
|
||||||
|
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||||
|
- **Discovered resources** — what the network reports. Produced by
|
||||||
|
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||||
|
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||||
|
infrastructure you have committed to.
|
||||||
|
|
||||||
|
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||||
|
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||||
|
which point it becomes catalog content like any other resource.
|
||||||
|
|
||||||
|
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||||
|
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||||
|
> guest is not a jump target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where discovered data comes from
|
||||||
|
|
||||||
|
| Source | What it reports |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||||
|
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||||
|
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||||
|
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||||
|
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||||
|
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||||
|
|
||||||
|
An agent is the most authoritative of these: it runs *on* the machine it
|
||||||
|
describes. A network scan is the least — it only knows what answered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How results are matched to existing resources
|
||||||
|
|
||||||
|
Every source runs through one reconciler, so two sources seeing the same
|
||||||
|
machine converge on one resource instead of creating duplicates. Matching is
|
||||||
|
tried in order of precision:
|
||||||
|
|
||||||
|
1. **MAC address** — the strongest signal, compared across every interface.
|
||||||
|
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||||
|
3. **Slug, name, or base hostname** — last resort.
|
||||||
|
|
||||||
|
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||||
|
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||||
|
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||||
|
template is the same machine.)
|
||||||
|
|
||||||
|
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||||
|
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||||
|
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||||
|
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||||
|
tie-break within a rank. This is why a device UniFi knows only as
|
||||||
|
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||||
|
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||||
|
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||||
|
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoting a discovered resource
|
||||||
|
|
||||||
|
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||||
|
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||||
|
correct it before committing. Saving marks it managed and provisions its
|
||||||
|
[LDAP groups](groups.html).
|
||||||
|
|
||||||
|
Each row shows what the directory knows about the device: its source(s), its
|
||||||
|
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||||
|
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||||
|
looks wrong, that detail is where to start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale results
|
||||||
|
|
||||||
|
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||||
|
stops reporting one for long enough it is marked
|
||||||
|
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||||
|
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||||
|
|
||||||
|
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||||
|
downtime does not look like decommissioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the stack discovers about itself
|
||||||
|
|
||||||
|
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||||
|
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||||
|
discovery plugin then finds the containers backing them. Containers belonging to
|
||||||
|
the theta-suite compose project are recognised and attached to the service they
|
||||||
|
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||||
|
empty Discovered Inventory rather than five things demanding attention.
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Group & Permission Model
|
||||||
|
nav_order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theta42 Group & Permission Model
|
||||||
|
|
||||||
|
This is the canonical reference for how **groups and permissions work** across the
|
||||||
|
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
|
||||||
|
hosts** should read and use them. It is written to be implementable by both humans
|
||||||
|
and LLM agents.
|
||||||
|
|
||||||
|
Everything below assumes LDAP is the single source of truth for identity and group
|
||||||
|
membership. Group membership is managed in the **SSO Manager Directory**, generated
|
||||||
|
from adopted resources — there is **no standalone "Groups" page**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principles
|
||||||
|
|
||||||
|
1. **Groups are a projection of the resource graph.** Every adopted host and app
|
||||||
|
in the Directory gets its own groups, auto-created from its identity. Group
|
||||||
|
membership is managed on the resource's modal.
|
||||||
|
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
|
||||||
|
hosts; an app administers apps. They do not inherit from each other.
|
||||||
|
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
|
||||||
|
`admin` implies `access`. Capabilities are explicit and never implied by
|
||||||
|
`admin`.
|
||||||
|
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
|
||||||
|
the site slug.
|
||||||
|
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
|
||||||
|
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
|
||||||
|
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
|
||||||
|
bloat. Only the meta groups are never imported by hosts.
|
||||||
|
6. **The directory is the only place groups are created.** `god_admin` is the sole
|
||||||
|
group that does not belong to a resource or site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Group schema
|
||||||
|
|
||||||
|
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
|
||||||
|
`<capability>` = an opaque, app-defined capability token (see §4).
|
||||||
|
|
||||||
|
| Group | Scope | Meaning |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
|
||||||
|
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
|
||||||
|
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
|
||||||
|
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
|
||||||
|
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
|
||||||
|
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
|
||||||
|
| `S_host_<host>_access` | host | Access to host `<host>`. |
|
||||||
|
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
|
||||||
|
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
|
||||||
|
| `S_apps_access` | site | Access to **all apps** at `S`. |
|
||||||
|
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
|
||||||
|
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
|
||||||
|
| `S_app_<app>_access` | app | Access to app `<app>`. |
|
||||||
|
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
|
||||||
|
|
||||||
|
### Meta groups (implicit membership — not POSIX, no gidNumber)
|
||||||
|
|
||||||
|
| Group | Scope | Meaning |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `everyone` | global | **All authenticated users**, any site. |
|
||||||
|
| `S_everyone` | site | **All authenticated users** at site `S`. |
|
||||||
|
|
||||||
|
These are resolved by the directory (any authenticated user passes), never
|
||||||
|
enumerated as LDAP members, and cannot be used as Unix groups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Naming, normalization & reserved rules
|
||||||
|
|
||||||
|
- The **structural delimiter is `_`**. It appears only between the fixed segments
|
||||||
|
of a group name.
|
||||||
|
- **Site, host, and app slugs never contain `_`.** Normalize to lowercase;
|
||||||
|
spaces and `_` → `-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a
|
||||||
|
site `Main Office` produce slugs `web-01` and `main-office`.
|
||||||
|
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
|
||||||
|
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
|
||||||
|
if a host were named `admin` (that host would be `S_host_admin_admin`).
|
||||||
|
- **The last segment is the level.** If it is `admin` or `access` it is a known
|
||||||
|
level; any other value is an **opaque capability** owned by a downstream app.
|
||||||
|
- **Total length budget:** keep a group cn under ~120 chars; reject group
|
||||||
|
creation that would exceed it.
|
||||||
|
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
|
||||||
|
generated on the host by SSSD for only the groups that host imports (see §8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Levels and opaque capabilities
|
||||||
|
|
||||||
|
- **`admin`** — manage (create/update/delete/config) the resource.
|
||||||
|
- **`access`** — use/read the resource.
|
||||||
|
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
|
||||||
|
manages membership and exposes the group to the app; **the downstream app
|
||||||
|
defines and enforces what the capability means** (e.g. `emby_admin`,
|
||||||
|
`gitea_maintain`, `reboot`, `backup`).
|
||||||
|
|
||||||
|
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
|
||||||
|
Everything else on a resource group is treated as an opaque capability group and
|
||||||
|
passed through to consumers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Permission resolution (inheritance)
|
||||||
|
|
||||||
|
Define a user's **effective permission** on a resource by checking, from most
|
||||||
|
specific to most general, whether they are a member of any applicable group. The
|
||||||
|
rule: a higher group implies everything below it.
|
||||||
|
|
||||||
|
### On host `H` at site `S`
|
||||||
|
|
||||||
|
| Wanted | Granted if the user is a member of **any** of |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
|
||||||
|
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
|
||||||
|
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
|
||||||
|
|
||||||
|
### On app `A` at site `S`
|
||||||
|
|
||||||
|
Identical, with `app`/`apps` substituted for `host`/`hosts`.
|
||||||
|
|
||||||
|
### Management console (SSO / Proxy / Jump-Host)
|
||||||
|
|
||||||
|
Each console is registered as an **app** on its site, so console admin is:
|
||||||
|
|
||||||
|
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
|
||||||
|
|
||||||
|
### Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
def effective(resource, level_or_cap, site):
|
||||||
|
if user in "god_admin": return True
|
||||||
|
if user in f"{site}_super_admin": return True
|
||||||
|
if level_or_cap in ("admin","access"):
|
||||||
|
agg = f"{site}_{resource.kind}s_{level_or_cap}"
|
||||||
|
if user in agg: return True
|
||||||
|
specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}"
|
||||||
|
if user in specific: return True
|
||||||
|
if level_or_cap == "access": return effective(resource, "admin", site)
|
||||||
|
if level_or_cap == "admin": return False # access does not imply admin
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
|
||||||
|
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Where groups live — the Directory, generated from adopted resources
|
||||||
|
|
||||||
|
- There is **no standalone Groups page.** Group creation/management happens on an
|
||||||
|
**adopted resource** in the Directory.
|
||||||
|
- When a host or app is **adopted** (promoted from Discovered Inventory to
|
||||||
|
managed), the directory auto-creates its `_admin` and `_access` groups (and
|
||||||
|
site aggregates if configured). Capability groups are created on demand.
|
||||||
|
- Membership (add/remove users) and capability grants are managed on that
|
||||||
|
resource's modal.
|
||||||
|
- Deleting a resource removes its per-resource groups.
|
||||||
|
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
|
||||||
|
global `god_admin`/`everyone` are managed at the site level (not on a single
|
||||||
|
host/app resource).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multi-site isolation
|
||||||
|
|
||||||
|
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
|
||||||
|
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
|
||||||
|
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
|
||||||
|
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
|
||||||
|
`everyone` cross site boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
|
||||||
|
|
||||||
|
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
|
||||||
|
There are hundreds of them and only a handful matter on any given host, so we do
|
||||||
|
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
|
||||||
|
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
|
||||||
|
This keeps the directory clean and the per-host surface tiny.
|
||||||
|
|
||||||
|
### SSSD — generate GIDs on the fly, import only what you need
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[domain/example]
|
||||||
|
id_provider = ldap
|
||||||
|
auth_provider = ldap
|
||||||
|
ldap_uri = ldaps://ldap.example
|
||||||
|
ldap_search_base = dc=example,dc=com
|
||||||
|
|
||||||
|
# groupOfNames (RFC 2307bis) schema
|
||||||
|
ldap_schema = rfc2307bis
|
||||||
|
ldap_group_object_class = groupOfNames
|
||||||
|
ldap_group_member = member
|
||||||
|
|
||||||
|
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
|
||||||
|
ldap_id_mapping = true
|
||||||
|
ldap_group_uuid = entryUUID
|
||||||
|
|
||||||
|
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
|
||||||
|
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
|
||||||
|
```
|
||||||
|
|
||||||
|
Key ideas:
|
||||||
|
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
|
||||||
|
stable GID for any group it imports, so **no `gidNumber` attribute is required**
|
||||||
|
in LDAP.
|
||||||
|
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
|
||||||
|
match, discarding the other hundreds. After changing the filter, clear the
|
||||||
|
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
|
||||||
|
`getent group <cn>`.
|
||||||
|
|
||||||
|
### What filter to use — the naming convention is the answer
|
||||||
|
|
||||||
|
A host should import its **own** resource groups (plus any explicitly granted
|
||||||
|
ones). Because the schema is predictable, `ldap-client` can generate the per-host
|
||||||
|
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
|
||||||
|
at site `main-office` imports:
|
||||||
|
|
||||||
|
```
|
||||||
|
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
|
||||||
|
(cn=main-office_host_web01_admin)
|
||||||
|
(cn=main-office_host_web01_sudo)))
|
||||||
|
```
|
||||||
|
|
||||||
|
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
|
||||||
|
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
|
||||||
|
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
|
||||||
|
|
||||||
|
### Aliasing an LDAP group into a local group (e.g. `input`)
|
||||||
|
|
||||||
|
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
|
||||||
|
Two host-side mechanisms cover it:
|
||||||
|
|
||||||
|
- **pam_exec** — a script in the login stack adds the user to the local group for
|
||||||
|
the session:
|
||||||
|
```sh
|
||||||
|
#!/bin/bash
|
||||||
|
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
|
||||||
|
```
|
||||||
|
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
|
||||||
|
`/etc/pam.d/common-session`.
|
||||||
|
|
||||||
|
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
|
||||||
|
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
|
||||||
|
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
|
||||||
|
members regardless of the local GID.
|
||||||
|
|
||||||
|
### Meta groups
|
||||||
|
|
||||||
|
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
|
||||||
|
implicit membership and are resolved by the directory only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Downstream-app consumption guide
|
||||||
|
|
||||||
|
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
|
||||||
|
membership from LDAP and interprets it as follows:
|
||||||
|
|
||||||
|
1. **Discover the user's groups** — bind with the user's credentials (or use a
|
||||||
|
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
|
||||||
|
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
|
||||||
|
the `memberOf` reverse attribute on the user's entry.
|
||||||
|
2. **Match each group to a scope:**
|
||||||
|
- `god_admin` → the user is a global administrator.
|
||||||
|
- `{site}_super_admin` → site administrator for that site.
|
||||||
|
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
|
||||||
|
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
|
||||||
|
- `everyone` / `{site}_everyone` → the user is implicitly a member.
|
||||||
|
3. **Interpret the last segment:**
|
||||||
|
- `admin` → full control of that resource.
|
||||||
|
- `access` → read/use.
|
||||||
|
- anything else → a capability **you** define; act on it or ignore it.
|
||||||
|
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
|
||||||
|
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
|
||||||
|
`{site}_app_emby_emby_admin` administers Emby.
|
||||||
|
|
||||||
|
The app must **never** treat an unknown last segment as `admin` or `access`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Migration from the legacy `app_*` groups
|
||||||
|
|
||||||
|
The current global groups (`app_sso_admin`, `app_super_admin`,
|
||||||
|
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
|
||||||
|
|
||||||
|
| Legacy | New |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `app_super_admin` | `god_admin` |
|
||||||
|
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
|
||||||
|
| `app_sso_directory_admin` | `S_app_sso_admin` |
|
||||||
|
| `app_jump_admin` | `S_app_jump_admin` |
|
||||||
|
|
||||||
|
During the transition the legacy groups may be kept as short-lived aliases that
|
||||||
|
resolve to the same effective permission; once everything is moved, remove them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. The management consoles are apps
|
||||||
|
|
||||||
|
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
|
||||||
|
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
|
||||||
|
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
|
||||||
|
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
|
||||||
|
self-consistent: the SSO is "just another app."
|
||||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 320 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
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/).
|
||||||
|
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
|
||||||
|
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||||
|
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
|
||||||
|
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||||
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||||
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||||
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||||
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||||
|
and token on its own schedule.
|
||||||
|
|
||||||
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||||
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||||
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||||
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||||
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||||
|
|
||||||
|
## Plugin types
|
||||||
|
|
||||||
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
|
`category`. Two built-in categories ship today:
|
||||||
|
|
||||||
|
**`discovery`** — scheduled scans that sync external assets into the
|
||||||
|
directory catalog:
|
||||||
|
|
||||||
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
- `docker` — Docker daemon discovery (containers as directory resources)
|
||||||
|
|
||||||
|
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
|
||||||
|
notifications:
|
||||||
|
|
||||||
|
- `twilio` — Twilio SMS
|
||||||
|
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
|
||||||
|
Discord, or any HTTP endpoint)
|
||||||
|
|
||||||
|
If no messaging plugin instance is enabled, the system falls back to the
|
||||||
|
legacy `voipms` integration configured directly in the SSO secrets.
|
||||||
|
|
||||||
|
### What the Proxmox plugin produces
|
||||||
|
|
||||||
|
One endpoint becomes one subtree:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox endpoint (cluster name, or the endpoint hostname)
|
||||||
|
└── node (hypervisor)
|
||||||
|
├── VM / template
|
||||||
|
└── LXC / template
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint resource stands for the cluster, not a machine, so it carries the
|
||||||
|
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
||||||
|
reached at made the reconciler merge it with the node answering on that address,
|
||||||
|
which produced a resource that was its own parent.
|
||||||
|
|
||||||
|
Every guest carries:
|
||||||
|
|
||||||
|
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
||||||
|
The MAC and the address on it are read from the same source, so they cannot be
|
||||||
|
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
||||||
|
zipped them by index, which attributed addresses to the wrong NIC on any
|
||||||
|
multi-NIC guest).
|
||||||
|
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
||||||
|
has an address.
|
||||||
|
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
||||||
|
a directory row traces back to the exact guest on the exact node.
|
||||||
|
|
||||||
|
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
||||||
|
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
||||||
|
172.x addresses would otherwise give the reconciler spurious matches.
|
||||||
|
|
||||||
|
A stopped VM still reports its MAC (read from the VM config rather than the
|
||||||
|
guest agent), and a DHCP-configured LXC gets its address from the running
|
||||||
|
container's interface list. Offline nodes are recorded with `status` rather than
|
||||||
|
skipped, so a hypervisor that is down does not look decommissioned and get
|
||||||
|
garbage-collected after a week.
|
||||||
|
|
||||||
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||||
|
// explicitly. `name`/`description` show up in the UI.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||||
|
|
||||||
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||||
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button: validate the config (don't do the work). Return
|
||||||
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||||
|
validate: async (config) => { … },
|
||||||
|
|
||||||
|
// The work. `run` is the generalized contract name; the discovery plugins
|
||||||
|
// also keep `discover` as an alias for back-compat. For `category:
|
||||||
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||||
|
run: async (config) => { return { resources, edges }; },
|
||||||
|
discover: async (config) => { return { resources, edges }; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||||
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||||
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||||
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||||
|
|
||||||
|
### Writing a custom plugin type
|
||||||
|
|
||||||
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||||
|
following the manifest above. New types are picked up at boot, so restart the
|
||||||
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||||
|
adding a new type still needs a restart.
|
||||||
|
|
||||||
|
## The Plugins page
|
||||||
|
|
||||||
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||||
|
/ `app_super_admin`):
|
||||||
|
|
||||||
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||||
|
source name + the URL the resource graph attributes results to), set a cron
|
||||||
|
schedule, and fill in the config form (secret fields are password inputs).
|
||||||
|
Creating it schedules it and kicks one immediate run.
|
||||||
|
- **Edit** — name, cron, and non-secret config.
|
||||||
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||||
|
field blank to keep its current value.
|
||||||
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||||
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||||
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||||
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||||
|
secret values masked.
|
||||||
|
|
||||||
|
| Method + path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||||
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||||
|
| `GET /api/plugins/:id` | one instance |
|
||||||
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||||
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||||
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||||
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||||
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||||
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||||
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||||
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||||
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||||
|
|
||||||
|
## Scheduler internals
|
||||||
|
|
||||||
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||||
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||||
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||||
|
prunes discovery resources not seen in > 7 days.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
|
||||||
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||||
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||||
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||||
|
table is non-empty and the static config is ignored — manage plugins from the
|
||||||
|
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Geo-Location Scaling (Replication)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Geo-Location Scaling (Replication)
|
||||||
|
|
||||||
|
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||||
|
|
||||||
|
## Why and when to use this?
|
||||||
|
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
|
||||||
|
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
|
||||||
|
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
|
||||||
|
|
||||||
|
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
|
||||||
|
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
|
||||||
|
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
|
||||||
|
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||||
|
|
||||||
|
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||||
|
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||||
|
|
||||||
|
### Example using `theta-env` / Docker Compose
|
||||||
|
|
||||||
|
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=1
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=2
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=3
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
|
## User Locations
|
||||||
|
|
||||||
|
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Vault Secrets
|
||||||
|
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can access the Vault UI from the application's top navigation bar.
|
||||||
|
|
||||||
|
### Creating Secrets
|
||||||
|
|
||||||
|
1. Click on the **New Secret** button.
|
||||||
|
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||||
|
3. Enter the **Secret Data** in JSON format. For example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "supersecretpassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Click **Save Secret**.
|
||||||
|
|
||||||
|
### Reading and Editing Secrets
|
||||||
|
|
||||||
|
* To view a secret, click on its name in the **Secrets List**.
|
||||||
|
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||||
|
|
||||||
|
### OpenBao Integration
|
||||||
|
|
||||||
|
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
|
||||||
|
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
|
||||||
|
mode, which auto-unseals with an in-memory store and loses everything on
|
||||||
|
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
|
||||||
|
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
|
||||||
|
way as the rest of the app (session cookie or a personal API token) — the
|
||||||
|
server resolves your OpenBao access itself and injects the right scoped
|
||||||
|
token; you never see or handle a raw OpenBao token as a UI user.
|
||||||
|
|
||||||
|
## Apps tab (admin)
|
||||||
|
|
||||||
|
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||||
|
|
||||||
|
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||||
|
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||||
|
|
||||||
|
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
|
|
||||||
|
## Shared tab
|
||||||
|
|
||||||
|
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||||
|
|
||||||
|
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
|
||||||
|
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
|
||||||
|
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
To read your own secrets programmatically, call the `/api/vault` proxy with
|
||||||
|
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
|
||||||
|
token. The server authenticates the request, resolves your own scoped
|
||||||
|
OpenBao access, and injects the real `X-Vault-Token` itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
|
||||||
|
curl -H "Authorization: Bearer sso_<id>_<secret>" \
|
||||||
|
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
An **external app** reading its own config uses the scoped token minted for
|
||||||
|
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||||
|
above for how that token is minted and what it's confined to.
|
||||||
|
|
||||||
|
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||||
|
never the intended path for day-to-day secret access — it's an
|
||||||
|
operator/maintenance credential (seeding, disaster recovery), kept in
|
||||||
|
`setup.env` and never passed to a service container. See
|
||||||
|
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
|
||||||
|
for the full token/policy model.
|
||||||
@@ -44,9 +44,10 @@ app.onListen.push(function(){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Theta Agent WebSockets
|
// Initialize Theta Agent WebSockets. The REST router is already mounted
|
||||||
require('./routes/api_agent')(app);
|
// synchronously above (see the /api/agent mount); this hook only wires the WS.
|
||||||
});
|
require('./routes/api_agent').initAgentWebSockets(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,
|
||||||
// uncompressed vendor JS/CSS files on every full page navigation (a
|
// uncompressed vendor JS/CSS files on every full page navigation (a
|
||||||
@@ -72,7 +73,8 @@ app.locals.ui = require('./utils/ui');
|
|||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
|
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
|
||||||
|
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
|
||||||
|
|
||||||
// Routes for front end content.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/index'));
|
app.use('/', require('./routes/index'));
|
||||||
@@ -104,6 +106,22 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
|||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
|
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
|
||||||
|
// not from an onListen hook — a router registered post-listen would sit behind
|
||||||
|
// the terminal 404 handler and make every /api/agent/* request 404. The agent
|
||||||
|
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
|
||||||
|
app.use('/api/agent', require('./routes/api_agent'));
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
|
||||||
|
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
|
||||||
|
// synchronously for the same reason as /api/agent — it must sit before the 404
|
||||||
|
// catch-all.
|
||||||
|
app.use('/api/v1/ldap', require('./routes/api_ldap'));
|
||||||
|
|
||||||
|
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
|
||||||
|
// caller is the agent itself (Bearer agent token), not an admin session.
|
||||||
|
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
@@ -125,6 +143,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
|||||||
const vaultBroker = require('./utils/vault_broker');
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
|
||||||
|
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ models.initORM().then(() => {
|
|||||||
initScheduler(conf.discovery).catch(err => {
|
initScheduler(conf.discovery).catch(err => {
|
||||||
console.error('Failed to initialize scheduler:', err);
|
console.error('Failed to initialize scheduler:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep external-app vault tokens alive: renew every stored accessor now and
|
||||||
|
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
|
||||||
|
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
|
||||||
|
if (process.env.VAULT_TOKEN) {
|
||||||
|
require('../utils/vault_broker').startAppTokenRenewal();
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ module.exports = {
|
|||||||
password: '__in secrets file__',
|
password: '__in secrets file__',
|
||||||
did: '__in secrets file__',
|
did: '__in secrets file__',
|
||||||
},
|
},
|
||||||
smtp: {
|
|
||||||
host: 'localhost',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
user: 'noreply@example.com',
|
|
||||||
pass: '__in secrets file__',
|
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
|
||||||
},
|
|
||||||
directory: {
|
directory: {
|
||||||
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||||
// component). When set, a host card in the catalog shows the real
|
// component). When set, a host card in the catalog shows the real
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A theta-agent enrolled against this SSO.
|
||||||
|
//
|
||||||
|
// Before this model existed the "agent token" was generated in the browser and
|
||||||
|
// never recorded anywhere, so the server had no way to tell an agent it issued
|
||||||
|
// from one someone invented -- /api/agent/ws accepted any string, and there was
|
||||||
|
// no way to revoke a token or to know that an agent existed while it was
|
||||||
|
// offline. The row is now the authority: an agent is only real if it is here.
|
||||||
|
//
|
||||||
|
// The raw token is shown exactly once, at enrollment. Only its SHA-256 lands in
|
||||||
|
// the database, so a database disclosure does not hand over working agent
|
||||||
|
// credentials. `tokenPrefix` is the first 8 characters, kept in the clear so the
|
||||||
|
// UI and logs can identify an agent without holding the secret.
|
||||||
|
class Agent extends Model {
|
||||||
|
// Tokens are compared by hash on every WebSocket connect. SHA-256 (not
|
||||||
|
// bcrypt) is deliberate: this runs on the connection path and the token is a
|
||||||
|
// 256-bit random value, not a human-chosen password, so there is nothing for
|
||||||
|
// a slow KDF to protect against here.
|
||||||
|
static hashToken(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented token to its (non-revoked) agent, or null. Every
|
||||||
|
// caller that authenticates an agent must go through here.
|
||||||
|
static async authenticate(rawToken) {
|
||||||
|
if (!rawToken || typeof rawToken !== 'string') return null;
|
||||||
|
const tokenHash = this.hashToken(rawToken);
|
||||||
|
const matches = await this.list({ where: { tokenHash } });
|
||||||
|
const agent = matches && matches[0];
|
||||||
|
if (!agent) return null;
|
||||||
|
if (agent.revoked) return null;
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll a new agent and return { agent, token }. The caller is responsible
|
||||||
|
// for showing `token` to the operator once and never storing it.
|
||||||
|
static async enroll({ name, resourceId, enrolledBy, description }) {
|
||||||
|
const token = this.generateToken();
|
||||||
|
const agent = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name || 'theta-agent',
|
||||||
|
description: description || null,
|
||||||
|
tokenHash: this.hashToken(token),
|
||||||
|
tokenPrefix: token.slice(0, 8),
|
||||||
|
resourceId: resourceId || null,
|
||||||
|
revoked: false,
|
||||||
|
enrolled_by: enrolledBy || null,
|
||||||
|
enrolled_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue a fresh token for an existing agent, invalidating the old one.
|
||||||
|
async rotateToken() {
|
||||||
|
const token = Agent.generateToken();
|
||||||
|
await this.update({
|
||||||
|
tokenHash: Agent.hashToken(token),
|
||||||
|
tokenPrefix: token.slice(0, 8),
|
||||||
|
revoked: false
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Never the raw token. See hashToken above.
|
||||||
|
tokenHash: { type: 'string', isRequired: true },
|
||||||
|
tokenPrefix: { type: 'string' },
|
||||||
|
// The host this agent runs on. Nullable so an agent can be enrolled
|
||||||
|
// before its host exists in the Directory, but the UI pushes for it:
|
||||||
|
// without this link there is nothing to hang resource control off, and
|
||||||
|
// the old code had to guess by matching hostnames to slugs.
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
enrolled_by: { type: 'string' },
|
||||||
|
enrolled_on: { type: 'integer' },
|
||||||
|
// Survives a restart, which the in-memory map did not: an agent that is
|
||||||
|
// installed but currently down is now distinguishable from one that was
|
||||||
|
// never enrolled.
|
||||||
|
last_seen: { type: 'integer' },
|
||||||
|
last_ip: { type: 'string' },
|
||||||
|
lastDiscovery: { type: 'json', default: {} },
|
||||||
|
lastTelemetry: { type: 'json', default: {} }
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shape the admin API returns. Never includes tokenHash.
|
||||||
|
toPublic(liveState) {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.tokenHash;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
connected: !!(liveState && liveState.connected),
|
||||||
|
// "Online" is a live-connection fact, not a stored one. A row with a
|
||||||
|
// last_seen from an hour ago is an installed agent that is down.
|
||||||
|
isOnline: !!(liveState && liveState.connected),
|
||||||
|
lastResponse: (liveState && liveState.lastResponse) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A join key: the one credential an operator hands out so a host can enroll
|
||||||
|
// itself. Requiring an admin to pre-register every machine before the agent
|
||||||
|
// would talk to them made adding a host a two-system chore -- installing the
|
||||||
|
// agent should be enough.
|
||||||
|
//
|
||||||
|
// A join key is NOT the agent's long-term credential. On first connect the
|
||||||
|
// server auto-enrolls the host and issues it a unique per-agent token, which
|
||||||
|
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
|
||||||
|
// operator experience to "one key" while still giving every host its own
|
||||||
|
// revocable identity -- revoking a single agent means something, and a host
|
||||||
|
// that is compromised does not hand over the credential for the whole fleet.
|
||||||
|
class AgentJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
// `tjk_` so an operator can tell a join key from an agent token at a
|
||||||
|
// glance -- they are handled very differently.
|
||||||
|
return 'tjk_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable join key, or null. Expiry and
|
||||||
|
// revocation are both enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Agent, AgentJoinKey };
|
||||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
|||||||
|
|
||||||
var transporter = nodemailer.createTransport(transportOpts);
|
var transporter = nodemailer.createTransport(transportOpts);
|
||||||
|
|
||||||
|
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
|
||||||
|
// ...: Sender is not same as SMTP authenticate username") require the
|
||||||
|
// envelope/header From to equal the authenticated user, or reject the
|
||||||
|
// send outright. If the operator hasn't set an explicit smtp.from,
|
||||||
|
// defaulting to the SMTP username is far more likely to actually send
|
||||||
|
// than a made-up noreply@theta42.com address that no relay authorized
|
||||||
|
// this account to send as.
|
||||||
var mailOpts = {
|
var mailOpts = {
|
||||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||||
to: to,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: message
|
html: message
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
|||||||
const { AccessRequest } = require('./access_request');
|
const { AccessRequest } = require('./access_request');
|
||||||
const { Webhook } = require('./webhook');
|
const { Webhook } = require('./webhook');
|
||||||
const { PluginInstance } = require('./plugin_instance');
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const { SharedSecret } = require('./shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -31,15 +35,48 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log('[initORM] ORM initialized successfully');
|
console.log('[initORM] ORM initialized successfully');
|
||||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||||
|
await healSchema();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[initORM] ORM initialization failed:', err.message);
|
console.error('[initORM] ORM initialization failed:', err.message);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||||
|
// which creates missing tables but never touches existing ones — so a column
|
||||||
|
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||||
|
// in an upgraded deployment's database and every query on the model fails
|
||||||
|
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||||
|
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||||
|
// any dialect via the query interface, and fail-soft per column so one bad
|
||||||
|
// attribute can't take the boot down.
|
||||||
|
async function healSchema() {
|
||||||
|
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||||
|
if (!adapter || !adapter.sequelize) return;
|
||||||
|
const sequelize = adapter.sequelize;
|
||||||
|
const qi = sequelize.getQueryInterface();
|
||||||
|
for (const SM of Object.values(sequelize.models)) {
|
||||||
|
const table = SM.getTableName();
|
||||||
|
let existing;
|
||||||
|
try { existing = await qi.describeTable(table); }
|
||||||
|
catch (e) { continue; } // no table yet — sync() handles creation
|
||||||
|
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||||
|
const col = attr.field || name;
|
||||||
|
if (existing[col]) continue;
|
||||||
|
try {
|
||||||
|
await qi.addColumn(table, col, attr);
|
||||||
|
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.initORM = initORM;
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -191,6 +191,24 @@ class Resource extends Model {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||||
|
// resources (Host, Cluster, Site, etc.).
|
||||||
|
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||||
|
if (visited.has(resourceId)) return [];
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const ancestors = [];
|
||||||
|
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []);
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId).catch(() => null);
|
||||||
|
if (!parent) continue;
|
||||||
|
ancestors.push(parent);
|
||||||
|
const higher = await this.findAllAncestors(parent.id, visited);
|
||||||
|
ancestors.push(...higher);
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResourceEdge extends Model {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecret — a secret the owner has published to the shared namespace so it
|
||||||
|
// can be shared with other users and/or downstream apps.
|
||||||
|
//
|
||||||
|
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
|
||||||
|
// never in the DB. This row is metadata only (owner + slug + description) and is
|
||||||
|
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
|
||||||
|
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
|
||||||
|
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
|
||||||
|
// edited to add `read` on the exact shared path (see vault_broker.js — policy
|
||||||
|
// content is parsed live at token use, so a grant takes effect immediately with
|
||||||
|
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
|
||||||
|
//
|
||||||
|
// `slug` is unique and immutable in practice — it is embedded in the shared path
|
||||||
|
// and in grantee policy rules, so changing it would require rewriting policies.
|
||||||
|
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
|
||||||
|
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
|
||||||
|
// ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class SharedSecret extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
|
||||||
|
// Unique so two owners can't collide on the same shared path.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The publishing user's uid — also the shared path's namespace segment.
|
||||||
|
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Optional human description shown in the Shared tab.
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
|
||||||
|
static pathFor(ownerUid, slug) {
|
||||||
|
return `shared/${ownerUid}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
path() {
|
||||||
|
return SharedSecret.pathFor(this.ownerUid, this.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug (unique). Returns the row or null.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecret };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
|
||||||
|
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
|
||||||
|
// <secretId>".
|
||||||
|
//
|
||||||
|
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
|
||||||
|
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
|
||||||
|
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
|
||||||
|
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
|
||||||
|
// content live at token use, the grant applies to the grantee's existing token
|
||||||
|
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
|
||||||
|
//
|
||||||
|
// granteeType distinguishes the two principal kinds:
|
||||||
|
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
|
||||||
|
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
|
||||||
|
// capability is currently always 'read' (grantees are read-only); the column is
|
||||||
|
// a string so later capabilities could be added without a migration.
|
||||||
|
//
|
||||||
|
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
|
||||||
|
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const GRANTEE_TYPES = ['user', 'app'];
|
||||||
|
const CAPABILITIES = ['read'];
|
||||||
|
|
||||||
|
class SharedSecretGrant extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// FK to SharedSecret.id.
|
||||||
|
secretId: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
|
||||||
|
granteeType: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// The grantee's uid (for 'user') or app name (for 'app').
|
||||||
|
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Access level — 'read' today.
|
||||||
|
capability: { type: 'string', isRequired: true, default: 'read' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All grants for a given grantee (user uid or app name). Used to rebuild the
|
||||||
|
// grantee's policy content so every granted shared path is present/absent.
|
||||||
|
static async listForGrantee(granteeType, granteeId) {
|
||||||
|
return this.list({ where: { granteeType, granteeId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
|
||||||
@@ -14,7 +14,12 @@ async function send(to, message) {
|
|||||||
const registry = require('../services/plugin_registry');
|
const registry = require('../services/plugin_registry');
|
||||||
const pluginSecrets = require('../utils/plugin_secrets');
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
|
||||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||||
|
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||||
|
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||||
|
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||||
|
// the direct VoIP.ms path below.
|
||||||
|
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||||
if (instances.length > 0) {
|
if (instances.length > 0) {
|
||||||
const inst = instances[0];
|
const inst = instances[0];
|
||||||
const manifest = registry.getManifest(inst.pluginType);
|
const manifest = registry.getManifest(inst.pluginType);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
|
||||||
|
// from the vault UI (Apps tab), so sso can keep the token alive.
|
||||||
|
//
|
||||||
|
// The token itself is shown ONCE at mint and never stored (a stolen accessor
|
||||||
|
// cannot authenticate — it can only look up, renew, or revoke its token, and
|
||||||
|
// only the sso broker's policy grants those endpoints). App tokens are minted
|
||||||
|
// through the sso-app role as PERIODIC tokens: they live forever, but only if
|
||||||
|
// something renews them inside every period window. That something is sso's
|
||||||
|
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
|
||||||
|
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
|
||||||
|
// stays valid as long as sso itself is running, with no renewal code needed in
|
||||||
|
// the downstream app.
|
||||||
|
//
|
||||||
|
// One row per app name: re-minting an app's token revokes the previous token
|
||||||
|
// via its accessor (no zombie credentials) and replaces the row.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class VaultAppToken extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The external app's name — also its policy (app-<name>) and KV namespace
|
||||||
|
// (secret/apps/<name>/). Unique: one live token per app.
|
||||||
|
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The minted token's accessor (renew/revoke handle, cannot authenticate).
|
||||||
|
accessor: { type: 'string', isRequired: true, max: 128 },
|
||||||
|
// Renewal bookkeeping, updated by the renewal loop.
|
||||||
|
lastRenewedAt: { type: 'integer' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
static async getByName(name) {
|
||||||
|
const rows = await this.list({ where: { name } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VaultAppToken };
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.5",
|
"version": "1.30.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.5",
|
"version": "1.30.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -2344,9 +2344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -4294,9 +4294,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -5966,16 +5966,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
@@ -7726,9 +7726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7918,9 +7918,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.5",
|
"version": "1.31.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ module.exports = {
|
|||||||
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
{ 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: '' }
|
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
|
||||||
|
// Containers in this compose project are the stack's own. They are already
|
||||||
|
// represented in the catalog as services, so they are recorded as managed
|
||||||
|
// and linked to the service they implement instead of arriving as
|
||||||
|
// unmanaged strangers a fresh install has to triage.
|
||||||
|
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||||
|
// The catalog host these containers run on, so they land in the tree
|
||||||
|
// instead of as roots.
|
||||||
|
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
|
||||||
],
|
],
|
||||||
|
|
||||||
validate: async (config) => {
|
validate: async (config) => {
|
||||||
@@ -48,23 +56,57 @@ module.exports = {
|
|||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
const stackProject = (config.stackProject || '').trim();
|
||||||
|
const hostSlug = (config.hostSlug || '').trim();
|
||||||
|
|
||||||
for (const c of containers) {
|
for (const c of containers) {
|
||||||
|
const labels = c.Labels || {};
|
||||||
|
const composeProject = labels['com.docker.compose.project'] || '';
|
||||||
|
const composeService = labels['com.docker.compose.service'] || '';
|
||||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
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)}`;
|
|
||||||
|
// A container id changes every time the container is recreated,
|
||||||
|
// so an id-derived slug made `docker compose up` mint a brand-new
|
||||||
|
// resource on every deploy and orphan the previous one. Prefer
|
||||||
|
// identifiers that survive a recreate: the compose project+service
|
||||||
|
// it belongs to, else its name.
|
||||||
|
const stableKey = composeProject && composeService
|
||||||
|
? `${composeProject}-${composeService}`
|
||||||
|
: (name || c.Id.substring(0, 12));
|
||||||
|
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
|
||||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||||
|
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'container',
|
kind: 'container',
|
||||||
name: name,
|
name: composeService || name,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
metadata: {
|
metadata: {
|
||||||
image: c.Image,
|
image: c.Image,
|
||||||
state: c.State,
|
state: c.State,
|
||||||
status: c.Status,
|
status: c.Status,
|
||||||
ports: ports
|
ports: ports,
|
||||||
|
composeProject: composeProject || undefined,
|
||||||
|
composeService: composeService || undefined,
|
||||||
|
containerName: name,
|
||||||
|
sourceId: stableKey,
|
||||||
|
// Part of the deployment we are running inside: already
|
||||||
|
// accounted for, not something to promote.
|
||||||
|
managed: isOwnStack ? true : undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach the container to the service it implements when the
|
||||||
|
// catalog already has one under that slug (the bootstrap seeds
|
||||||
|
// `sso-manager`, `proxy`, `jump-host`, … using the same names
|
||||||
|
// compose uses). The reconciler drops an edge whose parent does
|
||||||
|
// not resolve, so an unmatched name is simply not linked.
|
||||||
|
if (isOwnStack && composeService) {
|
||||||
|
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
|
||||||
|
} else if (hostSlug) {
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ resources, edges });
|
resolve({ resources, edges });
|
||||||
|
|||||||
@@ -32,10 +32,13 @@ module.exports = {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||||
const scan = new nmap.NmapScan(targetRange);
|
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
|
||||||
scan.command.push('-Pn');
|
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
|
||||||
scan.command.push('-F'); // fast scan, 100 top ports
|
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
|
||||||
scan.command.push('--min-rate', '100'); // speed up the scan
|
// -F: fast scan (100 top ports)
|
||||||
|
// --min-rate 100: speed up scan rate
|
||||||
|
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
|
||||||
|
const scan = new nmap.NmapScan(targetRange, customFlags);
|
||||||
|
|
||||||
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
|
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,81 @@ const agent = new https.Agent({
|
|||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
|
||||||
|
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
|
||||||
|
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
|
||||||
|
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
|
||||||
|
// the pairing honest -- the previous code collected MACs and IPs into two flat
|
||||||
|
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
|
||||||
|
class Interfaces {
|
||||||
|
constructor() { this.byMac = new Map(); this.anonymous = []; }
|
||||||
|
|
||||||
|
// Interfaces that belong to something running INSIDE the guest -- container
|
||||||
|
// engines, overlay networks, VPNs -- rather than to the guest itself. A
|
||||||
|
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
|
||||||
|
// alongside its one real NIC, which is noise in the directory and, worse,
|
||||||
|
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
|
||||||
|
// Only applied to guests; a hypervisor's own bridges are how you reach it.
|
||||||
|
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
|
||||||
|
|
||||||
|
static isVirtualName(name) {
|
||||||
|
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
|
||||||
|
// the only place the Proxmox node network API exposes a physical NIC's MAC
|
||||||
|
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
|
||||||
|
// rather than leaving every hypervisor MAC-less.
|
||||||
|
static macFromIfaceName(name) {
|
||||||
|
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
|
||||||
|
if (!m) return null;
|
||||||
|
return m[1].toLowerCase().match(/.{2}/g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalizeMac(mac) {
|
||||||
|
const m = (mac || '').toLowerCase().trim();
|
||||||
|
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
|
||||||
|
if (m === '00:00:00:00:00:00') return null;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ips` are the addresses observed on this one NIC (may be empty for a
|
||||||
|
// stopped guest, where only the MAC is known).
|
||||||
|
add(mac, ips, name) {
|
||||||
|
const key = Interfaces.normalizeMac(mac);
|
||||||
|
const addrs = (ips || []).filter(Boolean);
|
||||||
|
if (!key) {
|
||||||
|
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
|
||||||
|
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.byMac.get(key);
|
||||||
|
if (existing) {
|
||||||
|
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
|
||||||
|
existing.ip = existing.ips[0] || null;
|
||||||
|
if (!existing.name && name) existing.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
|
||||||
|
|
||||||
|
// The address/MAC the directory shows in its single-value columns, and what
|
||||||
|
// the reconciler matches on. Prefer a NIC that actually has an address.
|
||||||
|
primaryIp() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip);
|
||||||
|
return withIp ? withIp.ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryMac() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip && i.mac);
|
||||||
|
if (withIp) return withIp.mac;
|
||||||
|
const first = this.toArray().find(i => i.mac);
|
||||||
|
return first ? first.mac : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
// drives the admin UI form and validation; fields flagged `secret:true` are
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
@@ -50,6 +125,45 @@ module.exports = {
|
|||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
|
||||||
|
// several unrelated roots in the Directory tree and nothing says where any
|
||||||
|
// of them came from. Every node discovered below is parented to this, so
|
||||||
|
// one endpoint == one subtree.
|
||||||
|
const endpointHost = (() => {
|
||||||
|
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
|
||||||
|
})();
|
||||||
|
const clusterName = await (async () => {
|
||||||
|
// /cluster/status names the cluster when one exists; a standalone node
|
||||||
|
// has no cluster entry, in which case the endpoint hostname is the name.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
|
||||||
|
return entry ? entry.name : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: clusterName || `Proxmox (${endpointHost})`,
|
||||||
|
slug: endpointSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'proxmox',
|
||||||
|
address: url,
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
sourceId: url,
|
||||||
|
// Deliberately NO `ip`/`interfaces`: this resource stands for the
|
||||||
|
// cluster (the API endpoint), not for a machine. Giving it the address
|
||||||
|
// it is reached at made the reconciler match it to the very node that
|
||||||
|
// answers on that address -- the endpoint and the node collapsed into
|
||||||
|
// one row, which then became its own parent. The cluster is identified
|
||||||
|
// by slug + sourceId instead, which nothing else can collide with.
|
||||||
|
interfaces: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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) {
|
if(!resNodes.ok) {
|
||||||
@@ -59,9 +173,57 @@ module.exports = {
|
|||||||
const nodes = (await resNodes.json()).data;
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.status !== 'online') continue;
|
// An offline node is still a real hypervisor that belongs in the
|
||||||
|
// directory -- skipping it entirely used to make it look decommissioned
|
||||||
|
// and let the reconciler's garbage collector archive it after a week of
|
||||||
|
// downtime. Record it, mark it down, and skip only the guest enumeration
|
||||||
|
// (which needs the node to answer).
|
||||||
|
const online = node.status === 'online';
|
||||||
|
|
||||||
const nodeSlug = `pve-node-${node.node}`;
|
const nodeSlug = `pve-node-${node.node}`;
|
||||||
|
|
||||||
|
// A hypervisor with no address is not actionable. Read its bridges/NICs
|
||||||
|
// so the node lands in the directory reachable and MAC-identified like
|
||||||
|
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
|
||||||
|
// normally the address you actually reach the hypervisor on.
|
||||||
|
const nodeIfaces = new Interfaces();
|
||||||
|
try {
|
||||||
|
const netRes = online
|
||||||
|
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
|
||||||
|
: { ok: false };
|
||||||
|
if (netRes.ok) {
|
||||||
|
const ifaceList = (await netRes.json()).data || [];
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
if (iface.iface === 'lo') continue;
|
||||||
|
const ip = iface.address || iface.cidr;
|
||||||
|
// This endpoint has no hwaddr field, so the MAC has to be recovered
|
||||||
|
// from a predictable interface name -- either this interface's own
|
||||||
|
// or, for a bridge, one of the physical ports beneath it.
|
||||||
|
let mac = Interfaces.macFromIfaceName(iface.iface);
|
||||||
|
if (!mac) {
|
||||||
|
for (const alt of (iface.altnames || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mac && iface.bridge_ports) {
|
||||||
|
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
|
||||||
|
mac = Interfaces.macFromIfaceName(port);
|
||||||
|
if (mac) break;
|
||||||
|
// The port may itself only carry the MAC in an altname.
|
||||||
|
const portDef = ifaceList.find(i => i.iface === port);
|
||||||
|
for (const alt of ((portDef && portDef.altnames) || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'host',
|
kind: 'host',
|
||||||
name: node.node,
|
name: node.node,
|
||||||
@@ -70,9 +232,19 @@ module.exports = {
|
|||||||
subType: 'hypervisor',
|
subType: 'hypervisor',
|
||||||
os: 'Proxmox VE',
|
os: 'Proxmox VE',
|
||||||
isProduction: true,
|
isProduction: true,
|
||||||
interfaces: []
|
status: node.status,
|
||||||
|
sourceId: `${node.node}`,
|
||||||
|
node: node.node,
|
||||||
|
interfaces: nodeIfaces.toArray(),
|
||||||
|
macAddress: nodeIfaces.primaryMac(),
|
||||||
|
ip: nodeIfaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
|
||||||
|
|
||||||
|
// Everything below asks the node itself; an offline node answers none of
|
||||||
|
// it, and its guests are already recorded from previous runs.
|
||||||
|
if (!online) continue;
|
||||||
|
|
||||||
// 2. Get VMs for this node
|
// 2. Get VMs for this node
|
||||||
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
@@ -82,10 +254,12 @@ module.exports = {
|
|||||||
const vmSlug = `vm-${vm.vmid}`;
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
const isTemplate = vm.template === 1;
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
// Enrich from QEMU guest agent if running. The agent is the only source
|
||||||
// Enrich from QEMU guest agent if running
|
// that knows which IP sits on which NIC, so pair them here rather than
|
||||||
|
// accumulating two flat lists (zipping those by index attributed IPs to
|
||||||
|
// the wrong MAC on any guest with more than one NIC).
|
||||||
if (vm.status === 'running') {
|
if (vm.status === 'running') {
|
||||||
try {
|
try {
|
||||||
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
@@ -93,35 +267,35 @@ module.exports = {
|
|||||||
const agentData = (await agentRes.json()).data;
|
const agentData = (await agentRes.json()).data;
|
||||||
if (agentData && agentData.result) {
|
if (agentData && agentData.result) {
|
||||||
for (const iface of agentData.result) {
|
for (const iface of agentData.result) {
|
||||||
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
|
// Docker bridges, veth pairs and VPN tunnels are the
|
||||||
if (iface['ip-addresses']) {
|
// guest's own plumbing, not NICs of the guest.
|
||||||
for (const ip of iface['ip-addresses']) {
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
|
const ips = (iface['ip-addresses'] || [])
|
||||||
ips.push(ip['ip-address']);
|
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
|
||||||
}
|
.map(ip => ip['ip-address']);
|
||||||
}
|
ifaces.add(iface['hardware-address'], ips, iface.name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich from VM config to at least get MAC if agent failed/stopped
|
// Enrich from VM config: the MAC is declared there whether or not the
|
||||||
|
// guest agent answered, so a stopped VM still gets a stable identity.
|
||||||
try {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
if (confData[`net${i}`]) {
|
||||||
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
|
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
|
||||||
if(m) macs.push(m[1].toLowerCase());
|
if(m) ifaces.add(m[1], [], `net${i}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -130,9 +304,14 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'vm',
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
vmid: vm.vmid,
|
vmid: vm.vmid,
|
||||||
|
// The Proxmox-side identity, so a resource can be traced back to the
|
||||||
|
// exact guest on the exact node it was discovered from.
|
||||||
|
sourceId: `${node.node}/qemu/${vm.vmid}`,
|
||||||
|
node: node.node,
|
||||||
isProduction: vm.status === 'running',
|
isProduction: vm.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
@@ -146,26 +325,45 @@ module.exports = {
|
|||||||
const lxcSlug = `lxc-${lxc.vmid}`;
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
const isTemplate = lxc.template === 1;
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
|
||||||
// Enrich from LXC config
|
// so read them off the same line instead of into parallel lists.
|
||||||
try {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
const line = confData[`net${i}`];
|
||||||
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
|
if (!line) continue;
|
||||||
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
|
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
|
||||||
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
|
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
|
||||||
if(ipMatch) ips.push(ipMatch[1]);
|
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
const nameMatch = line.match(/\bname=([^,]+)/);
|
||||||
|
if (hwMatch || ipMatch) {
|
||||||
|
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
// A DHCP-configured container has no IP in its config. Ask the running
|
||||||
|
// container's interface list so it lands in the directory addressable
|
||||||
|
// instead of as an IP-less row.
|
||||||
|
if (lxc.status === 'running' && !ifaces.primaryIp()) {
|
||||||
|
try {
|
||||||
|
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
|
||||||
|
if (ifRes.ok) {
|
||||||
|
for (const iface of ((await ifRes.json()).data || [])) {
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ip = (iface.inet || '').split('/')[0];
|
||||||
|
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -174,9 +372,12 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'lxc',
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
vmid: lxc.vmid,
|
vmid: lxc.vmid,
|
||||||
|
sourceId: `${node.node}/lxc/${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
isProduction: lxc.status === 'running',
|
isProduction: lxc.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
@@ -190,5 +391,8 @@ module.exports = {
|
|||||||
// `discover` as their implementation name for back-compat, and `run` is just
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
// an alias. Referenced via module.exports (not `this`) so it survives being
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
// detached and called as a bare function reference.
|
// detached and called as a bare function reference.
|
||||||
run: async (config) => module.exports.discover(config)
|
run: async (config) => module.exports.discover(config),
|
||||||
|
|
||||||
|
// Exported for unit tests only -- not part of the plugin contract.
|
||||||
|
_Interfaces: Interfaces
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||||
|
.top-nav a.active{
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -615,9 +615,10 @@ app.util = (function(app){
|
|||||||
// Reveal every .group-required-<cn> element the current user's groups entitle
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
// user who is in no groups — or who isn't logged in — simply never sees them.
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
// The synthetic 'login' group is special: it's true for any authenticated user.
|
||||||
app.auth.applyGroupVisibility = function(user){
|
app.auth.applyGroupVisibility = function(user){
|
||||||
var groups = app.auth.groupCNs(user);
|
var groups = app.auth.groupCNs(user);
|
||||||
if(!groups.length) return;
|
var isLoggedIn = !!user;
|
||||||
|
|
||||||
var style = document.getElementById('group-required-rules');
|
var style = document.getElementById('group-required-rules');
|
||||||
if(!style){
|
if(!style){
|
||||||
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
|
|||||||
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 'login' group is synthetic — it means "any authenticated user".
|
||||||
|
// Reveal .group-required-login for any logged-in user.
|
||||||
|
if(isLoggedIn){
|
||||||
|
try{
|
||||||
|
style.sheet.insertRule(
|
||||||
|
`.group-required-login { display: revert !important; }`,
|
||||||
|
style.sheet.cssRules.length
|
||||||
|
);
|
||||||
|
}catch(error){
|
||||||
|
// Ignore CSS escape errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
$( document ).ready(async function(){
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
BINARY_URL="${BINARY_URL:-}"
|
||||||
|
CONFIG_DIR="/etc/theta42"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||||
|
BIN_PATH="/usr/local/bin/theta-agent"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log() { echo "${GREEN}[+]${NC} $1"; }
|
||||||
|
error() { echo "${RED}[!]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# 1. Root check
|
||||||
|
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||||
|
error "This script must be run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install SSSD and PAM integration packages if missing
|
||||||
|
install_sssd_deps() {
|
||||||
|
if ! command -v sssd >/dev/null 2>&1; then
|
||||||
|
log "Installing SSSD and PAM integration dependencies..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
|
||||||
|
if command -v pam-auth-update >/dev/null 2>&1; then
|
||||||
|
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
|
||||||
|
fi
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
pacman -S --noconfirm sssd || true
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
zypper in -y sssd || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "SSSD is already installed."
|
||||||
|
fi
|
||||||
|
mkdir -p /etc/sssd
|
||||||
|
chmod 755 /etc/sssd
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Argument Parsing
|
||||||
|
URL=""
|
||||||
|
TOKEN=""
|
||||||
|
JOIN_KEY=""
|
||||||
|
PUBLIC_KEY=""
|
||||||
|
B64_CONFIG=""
|
||||||
|
INSTALL_SSSD=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--url)
|
||||||
|
URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--public-key)
|
||||||
|
PUBLIC_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--join-key)
|
||||||
|
JOIN_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--install-sssd|--ldap)
|
||||||
|
INSTALL_SSSD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
B64_CONFIG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validation: require credentials ONLY if config file does not already exist
|
||||||
|
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||||
|
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Resolve binary URL dynamically if not specified
|
||||||
|
if [ -z "$BINARY_URL" ]; then
|
||||||
|
if [ -n "$URL" ]; then
|
||||||
|
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
elif [ -n "$B64_CONFIG" ]; then
|
||||||
|
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
|
||||||
|
if [ -n "$EXTRACTED_URL" ]; then
|
||||||
|
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
|
||||||
|
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$BINARY_URL" ]; then
|
||||||
|
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Downloading binary from $BINARY_URL..."
|
||||||
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
|
||||||
|
chmod +x "$BIN_PATH.tmp"
|
||||||
|
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||||
|
|
||||||
|
# 4. Setup configuration
|
||||||
|
log "Preparing configuration directory $CONFIG_DIR..."
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
chmod 755 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
if [ -n "$B64_CONFIG" ]; then
|
||||||
|
log "Decoding and writing configuration from base64..."
|
||||||
|
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||||
|
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
log "Generating minimal configuration from arguments..."
|
||||||
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
|
server_url: "$URL"
|
||||||
|
auth_token: "$TOKEN"
|
||||||
|
join_key: "$JOIN_KEY"
|
||||||
|
public_key: "$PUBLIC_KEY"
|
||||||
|
location: "unknown"
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
ldap_tunnel: true
|
||||||
|
reboot: false
|
||||||
|
service_control: []
|
||||||
|
arbitrary_bash: false
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
log "Preserving existing configuration at $CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
|
||||||
|
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
|
||||||
|
install_sssd_deps
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Setup systemd service
|
||||||
|
log "Creating systemd service unit..."
|
||||||
|
cat <<EOF > "$SERVICE_FILE"
|
||||||
|
[Unit]
|
||||||
|
Description=Theta Agent Unified Endpoint Management
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$BIN_PATH
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
SyslogIdentifier=theta-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6. Start the agent
|
||||||
|
log "Enabling and starting Theta Agent..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable theta-agent
|
||||||
|
systemctl start theta-agent
|
||||||
|
|
||||||
|
log "Theta Agent installation complete!"
|
||||||
|
log "Verify status with: systemctl status theta-agent"
|
||||||
|
log "Check logs with: journalctl -u theta-agent -f"
|
||||||
@@ -1,53 +1,505 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
module.exports = function initAgentWebSockets(app) {
|
const express = require('express');
|
||||||
if (!app.wss) {
|
const middleware = require('../middleware/auth');
|
||||||
console.warn("WebSocket server for agents is not initialized.");
|
const permission = require('../utils/permission');
|
||||||
return;
|
const agentManager = require('../utils/agent_manager');
|
||||||
|
const agentKeys = require('../utils/agent_keys');
|
||||||
|
const ldapTunnel = require('../utils/ldap_tunnel');
|
||||||
|
const { Agent, AgentJoinKey } = require('../models/agent');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
|
// Commands that can change or run code on the host. They are signed with the
|
||||||
|
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||||
|
// its agent.yml.
|
||||||
|
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
|
||||||
|
|
||||||
|
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||||
|
// This is a plain Express Router exported directly so app.js can
|
||||||
|
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It
|
||||||
|
// must NOT be mounted from the onListen hook (which runs after the 404
|
||||||
|
// catch-all is already on the stack): a router registered behind that terminal
|
||||||
|
// handler would make every /api/agent/* request 404, no matter the WS server
|
||||||
|
// state. The WebSocket handler is separate (initAgentWebSockets below) and is
|
||||||
|
// the only part that needs the post-listen onListen hook.
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Structured audit line for anything that reaches a host. The agent channel can
|
||||||
|
// run arbitrary bash, so "who told which host to do what" has to be recoverable
|
||||||
|
// after the fact; previously nothing was recorded at all.
|
||||||
|
function logAgentAudit(action, details) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
component: 'agent',
|
||||||
|
action,
|
||||||
|
...details
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent WebSocket (/api/agent/ws) authenticates its own token against the
|
||||||
|
// Agent table (see initAgentWebSockets). These REST routes are admin-facing, so
|
||||||
|
// they're auth + admin gated.
|
||||||
|
router.use(middleware.auth);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fleet ---
|
||||||
|
router.get('/nodes', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keyStatus = agentKeys.status();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
agents: await agentManager.listAgents(),
|
||||||
|
// Base64 of the raw 32-byte key: what goes into agent.yml's `public_key`.
|
||||||
|
publicKey: await agentManager.publicKeyBase64(),
|
||||||
|
publicKeyPem: await agentManager.publicKeyPem(),
|
||||||
|
signingAvailable: agentKeys.status().available,
|
||||||
|
signingError: keyStatus.error || null
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Enrollment ---
|
||||||
|
// The token is minted HERE, not in the browser. It is returned exactly once;
|
||||||
|
// only its hash is stored, so it cannot be recovered afterwards -- rotate to
|
||||||
|
// get a new one.
|
||||||
|
router.post('/enroll', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, resourceId, description } = req.body || {};
|
||||||
|
if (!name || !String(name).trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.wss.on('connection', (ws, req) => {
|
if (resourceId) {
|
||||||
// Parse the token from query param or header (e.g. ?token=XYZ)
|
const { Resource } = require('../models/resource');
|
||||||
// For the beta, we will just accept it if a token is present.
|
const resource = await Resource.get(resourceId);
|
||||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
|
||||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
if (resource.kind !== 'host') {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
const { agent, token } = await Agent.enroll({
|
||||||
ws.close(4001, 'Unauthorized: Missing token');
|
name: String(name).trim(),
|
||||||
return;
|
description,
|
||||||
|
resourceId: resourceId || null,
|
||||||
|
enrolledBy: req.user.uid
|
||||||
|
});
|
||||||
|
|
||||||
|
logAgentAudit('enroll', { actor: req.user.uid, agentId: agent.id, agentName: agent.name, resourceId: resourceId || null });
|
||||||
|
|
||||||
|
const publicKey = await agentManager.publicKeyBase64();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
agent: agent.toPublic(agentManager.liveState(agent.id)),
|
||||||
|
// Shown once. The UI must make that clear.
|
||||||
|
token,
|
||||||
|
publicKey,
|
||||||
|
signingAvailable: agentKeys.status().available
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/nodes/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (req.body.name !== undefined) patch.name = req.body.name;
|
||||||
|
if (req.body.description !== undefined) patch.description = req.body.description;
|
||||||
|
if (req.body.resourceId !== undefined) {
|
||||||
|
if (req.body.resourceId) {
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const resource = await Resource.get(req.body.resourceId);
|
||||||
|
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
|
||||||
|
if (resource.kind !== 'host') {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.resourceId = req.body.resourceId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await agent.update(patch);
|
||||||
|
logAgentAudit('update', { actor: req.user.uid, agentId: agent.id, fields: Object.keys(patch) });
|
||||||
|
res.json({ status: 'ok', agent: updated.toPublic(agentManager.liveState(agent.id)) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke: the token stops authenticating immediately and any live socket is
|
||||||
|
// dropped, so revocation takes effect without waiting for a reconnect.
|
||||||
|
router.post('/nodes/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
await agent.update({ revoked: true });
|
||||||
|
agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
|
||||||
|
logAgentAudit('revoke', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/nodes/:id/rotate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
const token = await agent.rotateToken();
|
||||||
|
// The old token is dead the moment it is replaced; drop the socket that was
|
||||||
|
// using it so the agent reconnects with the new one.
|
||||||
|
agentManager.disconnect(agent.id, 4004, 'Token rotated');
|
||||||
|
logAgentAudit('rotate', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok', token, publicKey: await agentManager.publicKeyBase64() });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/nodes/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
agentManager.disconnect(agent.id, 4003, 'Enrollment deleted');
|
||||||
|
await agent.delete();
|
||||||
|
logAgentAudit('delete', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Join keys ---
|
||||||
|
// One key an operator hands out; hosts that present it enroll themselves and
|
||||||
|
// are immediately issued their own per-agent token. Listing never returns the
|
||||||
|
// key itself -- only its prefix and usage.
|
||||||
|
router.get('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await AgentJoinKey.list();
|
||||||
|
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which hosts enrolled through a given key. There is no stored relation --
|
||||||
|
// join keys are exchanged for a per-agent token immediately, and from then on
|
||||||
|
// the agent's own identity is what matters -- so this matches on the
|
||||||
|
// human-readable trace `Agent.enroll` already leaves in `description`
|
||||||
|
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
|
||||||
|
// are 12 random hex chars, so a collision is not a practical concern.
|
||||||
|
router.get('/join-keys/:id/agents', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
const marker = `join key ${key.keyPrefix}`;
|
||||||
|
const agents = await Agent.list();
|
||||||
|
const matches = agents.filter(a => (a.description || '').includes(marker));
|
||||||
|
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
const { key, raw } = await AgentJoinKey.issue({
|
||||||
|
label: (label && String(label).trim()) || 'default',
|
||||||
|
createdBy: req.user.uid,
|
||||||
|
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||||
|
});
|
||||||
|
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Shown once; only the hash is stored.
|
||||||
|
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.update({ revoked: true });
|
||||||
|
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Agents already enrolled keep working -- they hold their own tokens now,
|
||||||
|
// which is the whole point of exchanging the join key rather than using it
|
||||||
|
// as the long-term credential.
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.delete();
|
||||||
|
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Commands ---
|
||||||
|
// Addressed by agent id, not by token: a token is a credential and has no
|
||||||
|
// business travelling in a URL, being logged, or sitting in browser history.
|
||||||
|
router.post('/nodes/:id/command', async (req, res, next) => {
|
||||||
|
const { command, payload, isHighRisk } = req.body || {};
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Command type is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
if (agent.revoked) return res.status(403).json({ status: 'error', message: 'agent enrollment is revoked' });
|
||||||
|
|
||||||
|
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
|
||||||
|
const msg = await agentManager.sendCommand(agent, command, payload || {}, requiresSigning);
|
||||||
|
|
||||||
|
logAgentAudit('command', {
|
||||||
|
actor: req.user.uid,
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
resourceId: agent.resourceId || null,
|
||||||
|
command,
|
||||||
|
signed: requiresSigning
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'ok', sentMessage: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logAgentAudit('command_failed', { actor: req.user && req.user.uid, agentId: req.params.id, command, error: err.message });
|
||||||
|
res.status(400).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.HIGH_RISK_COMMANDS = HIGH_RISK_COMMANDS;
|
||||||
|
|
||||||
|
module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||||
|
// WebSocket handler only needs the WS server; runs from the onListen hook.
|
||||||
|
if (!app.wss) return;
|
||||||
|
|
||||||
|
// Warm the signing key at boot so a misconfigured OpenBao policy is a loud
|
||||||
|
// startup error rather than a surprise the first time someone reboots a host.
|
||||||
|
agentKeys.load().then(keys => {
|
||||||
|
if (!keys) console.error(`[Theta Agent] signing key unavailable — high-risk commands will be refused. ${agentKeys.status().error || ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.wss.on('connection', async (ws, req) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||||
|
const remoteAddr = req.socket.remoteAddress;
|
||||||
|
|
||||||
|
// Authenticate BEFORE doing anything else: no registration, no welcome
|
||||||
|
// payload, no acknowledgement that the token was close. Until this passes
|
||||||
|
// the peer is an anonymous stranger, and the old code treated it as a
|
||||||
|
// trusted node purely for presenting a non-empty string.
|
||||||
|
let agent = null;
|
||||||
|
let issuedToken = null; // set when this connection auto-enrolled
|
||||||
|
try {
|
||||||
|
agent = await Agent.authenticate(token);
|
||||||
|
|
||||||
|
// Not a known agent token -- try it as a join key. This is what makes
|
||||||
|
// "install the agent with a key and the host appears" work without an
|
||||||
|
// admin pre-registering every machine. The join key is exchanged for a
|
||||||
|
// per-agent token below, so it never becomes the host's long-term
|
||||||
|
// credential.
|
||||||
|
if (!agent) {
|
||||||
|
const joinKey = await AgentJoinKey.authenticate(token);
|
||||||
|
if (joinKey) {
|
||||||
|
const hostname = (url.searchParams.get('hostname') || '').trim();
|
||||||
|
let existingAgent = null;
|
||||||
|
if (hostname) {
|
||||||
|
const matches = await Agent.list({ where: { name: hostname } });
|
||||||
|
existingAgent = matches && matches.find(a => !a.revoked);
|
||||||
|
}
|
||||||
|
if (existingAgent) {
|
||||||
|
const newToken = await existingAgent.rotateToken();
|
||||||
|
agent = existingAgent;
|
||||||
|
issuedToken = newToken;
|
||||||
|
} else {
|
||||||
|
const enrolled = await Agent.enroll({
|
||||||
|
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||||
|
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||||
|
enrolledBy: `join-key:${joinKey.label}`
|
||||||
|
});
|
||||||
|
agent = enrolled.agent;
|
||||||
|
issuedToken = enrolled.token;
|
||||||
|
}
|
||||||
|
await joinKey.update({
|
||||||
|
use_count: (joinKey.use_count || 0) + 1,
|
||||||
|
last_used_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).catch(() => {});
|
||||||
|
logAgentAudit('join', {
|
||||||
|
agentId: agent.id, agentName: agent.name, remoteAddr,
|
||||||
|
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
|
||||||
|
});
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Theta Agent] authentication lookup failed:', err.message);
|
||||||
|
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
// Deliberately indistinguishable for unknown vs revoked vs missing: a
|
||||||
|
// caller probing tokens learns nothing about which part was wrong.
|
||||||
|
logAgentAudit('auth_rejected', { remoteAddr, tokenPrefix: token ? String(token).slice(0, 8) : null });
|
||||||
|
try { ws.close(4001, 'Unauthorized'); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) connected from ${remoteAddr}`);
|
||||||
|
logAgentAudit('connected', { agentId: agent.id, agentName: agent.name, remoteAddr });
|
||||||
|
// Must stay synchronous, and the listeners below must be attached in this
|
||||||
|
// same tick: the agent sends `discovery` the instant the socket opens, and
|
||||||
|
// `ws` discards messages emitted while no listener is attached.
|
||||||
|
agentManager.registerAgent(agent, ws, remoteAddr);
|
||||||
|
|
||||||
|
if (issuedToken) {
|
||||||
|
const publicKey = await agentManager.publicKeyBase64();
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'config',
|
||||||
|
payload: {
|
||||||
|
enrolled: true,
|
||||||
|
auth_token: issuedToken,
|
||||||
|
public_key: publicKey
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('message', async (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
if (!data || typeof data.type !== 'string') return;
|
||||||
|
|
||||||
|
// Re-read the row per message so a revoke mid-session takes effect on
|
||||||
|
// the next thing the agent says, not only on reconnect.
|
||||||
|
const current = await Agent.get(agent.id).catch(() => null);
|
||||||
|
if (!current || current.revoked) {
|
||||||
|
try { ws.close(4003, 'Enrollment revoked'); } catch (e) {}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Theta Agent] Agent connected from ${req.socket.remoteAddress}`);
|
const payload = data.payload || {};
|
||||||
|
|
||||||
ws.on('message', (message) => {
|
switch (data.type) {
|
||||||
try {
|
case 'discovery':
|
||||||
const data = JSON.parse(message);
|
await agentManager.handleDiscovery(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
|
||||||
// Example handling incoming telemetry
|
if (payload.capabilities && payload.capabilities.configure_ldap) {
|
||||||
if (data.type === 'telemetry') {
|
const conf = require('@simpleworkjs/conf');
|
||||||
// Send to discovery service or log
|
const os = require('os');
|
||||||
// console.log(`[Theta Agent] Received telemetry from ${data.host}`);
|
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
|
||||||
|
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
|
||||||
// We can publish it to the event bus for the UI
|
|
||||||
if(app.contoller && app.contoller.ps) {
|
const lanIps = [];
|
||||||
app.contoller.ps.publish('agent.telemetry', data);
|
const ifaces = os.networkInterfaces();
|
||||||
}
|
for (const dev in ifaces) {
|
||||||
|
for (const details of ifaces[dev]) {
|
||||||
|
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}
|
||||||
console.error("[Theta Agent] Error parsing message:", err);
|
const uriList = [
|
||||||
}
|
`ldapi://%2frun%2ftheta%2fldap.sock`,
|
||||||
});
|
`ldap://127.0.0.1:3890`,
|
||||||
|
`ldap://127.0.0.1:389`,
|
||||||
|
`ldap://${ssoHost}:389`,
|
||||||
|
`ldaps://${ssoHost}:636`,
|
||||||
|
...lanIps.map(ip => `ldap://${ip}:389`)
|
||||||
|
];
|
||||||
|
const ldapUris = [...new Set(uriList)].join(', ');
|
||||||
|
|
||||||
ws.on('close', () => {
|
const sssdConfig = `[sssd]
|
||||||
console.log(`[Theta Agent] Agent disconnected`);
|
config_file_version = 2
|
||||||
});
|
domains = default
|
||||||
|
|
||||||
// Example: Send a welcome config payload to the agent
|
[domain/default]
|
||||||
ws.send(JSON.stringify({
|
id_provider = ldap
|
||||||
type: 'config',
|
auth_provider = ldap
|
||||||
payload: {
|
chpass_provider = ldap
|
||||||
message: 'Welcome to SSO Manager C2'
|
sudo_provider = ldap
|
||||||
|
ldap_uri = ${ldapUris}
|
||||||
|
ldap_search_base = ${ldapBaseDn}
|
||||||
|
ldap_user_search_base = ou=people,${ldapBaseDn}
|
||||||
|
ldap_group_search_base = ou=groups,${ldapBaseDn}
|
||||||
|
ldap_sudo_search_base = ou=people,${ldapBaseDn}
|
||||||
|
ldap_schema = rfc2307bis
|
||||||
|
ldap_user_object_class = posixAccount
|
||||||
|
ldap_user_name = uid
|
||||||
|
ldap_user_ssh_public_key = sshPublicKey
|
||||||
|
ldap_group_object_class = groupOfNames
|
||||||
|
ldap_group_member = member
|
||||||
|
ldap_id_mapping = false
|
||||||
|
ldap_id_use_start_tls = false
|
||||||
|
ldap_tls_reqcert = never
|
||||||
|
cache_credentials = true
|
||||||
|
entry_cache_timeout = 600
|
||||||
|
entry_cache_user_timeout = 600
|
||||||
|
entry_cache_group_timeout = 600
|
||||||
|
entry_cache_sudo_timeout = 600
|
||||||
|
refresh_expired_interval = 300
|
||||||
|
`;
|
||||||
|
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
|
||||||
|
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}));
|
break;
|
||||||
|
case 'telemetry':
|
||||||
|
await agentManager.handleTelemetry(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
await agentManager.handleHeartbeat(current, payload, ws);
|
||||||
|
break;
|
||||||
|
case 'response':
|
||||||
|
await agentManager.handleResponse(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
|
||||||
|
break;
|
||||||
|
case 'ldap_tunnel':
|
||||||
|
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
|
||||||
|
// and pipe the response back (DESIGN.md §4).
|
||||||
|
ldapTunnel.handleTunnel(current.id, ws, payload);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Theta Agent] Error handling message:', err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
|
||||||
|
agentManager.unregisterAgent(agent.id, ws);
|
||||||
|
ldapTunnel.cleanup(agent.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial welcome/config payload. When this connection enrolled via a
|
||||||
|
// join key it also carries the credentials the agent should persist and use
|
||||||
|
// from now on: its own token, and the public key it must pin to verify
|
||||||
|
// signed commands. Handing the public key over here is what removes the
|
||||||
|
// last manual step -- an agent installed with only a join key ends up fully
|
||||||
|
// configured without anyone copying values between two machines.
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
message: 'Connected to SSO Manager C2',
|
||||||
|
protocol_version: '1.2.0',
|
||||||
|
agent_id: agent.id
|
||||||
|
};
|
||||||
|
if (issuedToken) {
|
||||||
|
payload.enrolled = true;
|
||||||
|
payload.auth_token = issuedToken;
|
||||||
|
payload.public_key = await agentManager.publicKeyBase64();
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'config', payload }));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Agent-facing operations (DESIGN.md §5, §6). These are NOT admin-gated: the
|
||||||
|
// caller is the agent itself, authenticated by its own token (the same one it
|
||||||
|
// presents on its WSS channel). Mounted at /api/v1/agent.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { authenticateAgent } = require('../utils/agent_auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /secrets — fetch node-scoped OpenBao secrets for the agent's own node.
|
||||||
|
//
|
||||||
|
// { paths: ["secret/data/nodes/<agent-id>/db"] }
|
||||||
|
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
|
||||||
|
//
|
||||||
|
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
|
||||||
|
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
|
||||||
|
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
|
||||||
|
// Vault token.
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
|
||||||
|
router.post('/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await authenticateAgent(req);
|
||||||
|
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
|
||||||
|
let { paths } = req.body || {};
|
||||||
|
let boundResource = null;
|
||||||
|
if (agent.resourceId) {
|
||||||
|
boundResource = await Resource.get(agent.resourceId).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0) {
|
||||||
|
paths = [`secret/data/nodes/${agent.id}/conf`];
|
||||||
|
if (boundResource && boundResource.slug) {
|
||||||
|
paths.push(`secret/data/resources/${boundResource.slug}/conf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed prefixes for this agent:
|
||||||
|
// 1. Node scope: secret/data/nodes/<agent.id>/
|
||||||
|
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
|
||||||
|
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
|
||||||
|
const allowedPrefixes = [`secret/data/nodes/${agent.id}/`];
|
||||||
|
if (boundResource && boundResource.slug) {
|
||||||
|
allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add granted shared resources
|
||||||
|
if (boundResource) {
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []);
|
||||||
|
for (const g of grants) {
|
||||||
|
const sharedSec = await SharedSecret.get(g.secretId).catch(() => null);
|
||||||
|
if (sharedSec && sharedSec.slug) {
|
||||||
|
allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`);
|
||||||
|
allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = {};
|
||||||
|
for (let p of paths) {
|
||||||
|
if (typeof p !== 'string') continue;
|
||||||
|
// Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar"
|
||||||
|
if (p.startsWith('resources/')) {
|
||||||
|
p = `secret/data/resources/${p.slice('resources/'.length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix));
|
||||||
|
if (!isAllowed) {
|
||||||
|
return res.status(403).json({ status: 'error', message: `path outside authorized scope: ${p}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await baoConf.request('GET', p);
|
||||||
|
if (r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
const rawMap = (body.data && body.data.data) || {};
|
||||||
|
const resolvedMap = {};
|
||||||
|
for (const [k, v] of Object.entries(rawMap)) {
|
||||||
|
const strV = String(v || '');
|
||||||
|
if (strV.startsWith('INHERIT:')) {
|
||||||
|
const parts = strV.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const targetSlug = parts[1];
|
||||||
|
const targetKey = parts[2];
|
||||||
|
const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`);
|
||||||
|
if (parentR.ok) {
|
||||||
|
const parentBody = await parentR.json().catch(() => ({}));
|
||||||
|
const parentMap = (parentBody.data && parentBody.data.data) || {};
|
||||||
|
resolvedMap[k] = parentMap[targetKey] || '';
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secrets[p] = resolvedMap;
|
||||||
|
} else {
|
||||||
|
secrets[p] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', secrets });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -128,4 +128,76 @@ router.post('/proxy', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send a test email to verify SMTP configuration
|
||||||
|
router.post('/test-email', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to, subject, body } = req.body || {};
|
||||||
|
if (!to) {
|
||||||
|
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send through the SAME sender every other feature uses (password reset,
|
||||||
|
// invites, OTP-by-email, notifications). A "test" that reimplements
|
||||||
|
// delivery proves nothing about whether real mail works.
|
||||||
|
//
|
||||||
|
// models/email.js exports `{Mail}`; requiring the module and calling
|
||||||
|
// `.send` on it directly -- as this did -- always threw
|
||||||
|
// "Email.send is not a function", so the button could never succeed.
|
||||||
|
const { Mail } = require('../models/email');
|
||||||
|
const testSubject = subject || 'SSO Manager Test Email';
|
||||||
|
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||||
|
|
||||||
|
await Mail.send(to, testSubject, testBody);
|
||||||
|
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||||
|
} catch(err) {
|
||||||
|
// A failed test is almost always a misconfiguration (wrong host, refused
|
||||||
|
// connection, bad credentials) -- the operator's to fix, and something the
|
||||||
|
// UI should be able to show them. Surfacing it as a 400 with the reason
|
||||||
|
// beats an opaque 500 carrying a raw stack-trace name.
|
||||||
|
return res.status(400).json({ error: err.message || 'Failed to send test email' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a test SMS to verify VoIP.ms configuration
|
||||||
|
router.post('/test-sms', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to, message } = req.body || {};
|
||||||
|
if (!to) {
|
||||||
|
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send through models/sms.js -- the same path every real SMS takes. It
|
||||||
|
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
|
||||||
|
// normalizes the destination to E.164 digits.
|
||||||
|
//
|
||||||
|
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
|
||||||
|
// No such endpoint exists: VoIP.ms's REST API is a GET against
|
||||||
|
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
|
||||||
|
// `method=sendSMS`. The fabricated URL returned an HTML page, so
|
||||||
|
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
|
||||||
|
// button reported that as the failure. It could never have sent anything.
|
||||||
|
const { SMS } = require('../models/sms');
|
||||||
|
const { PluginInstance } = require('../models/plugin_instance');
|
||||||
|
|
||||||
|
// A messaging plugin, when present, supplies its own credentials -- so
|
||||||
|
// requiring conf.voipms unconditionally would block a perfectly working
|
||||||
|
// setup from testing itself.
|
||||||
|
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
|
||||||
|
const voipmsConf = conf.voipms || {};
|
||||||
|
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
|
||||||
|
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
|
||||||
|
|
||||||
|
await SMS.send(to, testMessage);
|
||||||
|
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||||
|
} catch(err) {
|
||||||
|
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
|
||||||
|
// plugin's own error). Surface it as a 400 the UI can display rather than
|
||||||
|
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
|
||||||
|
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
|
|||||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
const groups = require('../utils/groups');
|
||||||
|
|
||||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
// the goal state, and a missing group (e.g. god_admin absent on a
|
||||||
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||||
// caller's real work.
|
// caller's real work.
|
||||||
async function nestGroup(childCn, parentCn) {
|
async function nestGroup(childCn, parentCn) {
|
||||||
@@ -29,6 +30,160 @@ async function nestGroup(childCn, parentCn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
|
// The directory is the single place groups are created, as a projection of the
|
||||||
|
// resource graph. These helpers materialize the group-inheritance lattice for
|
||||||
|
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
|
||||||
|
// All of them are idempotent, so calling them again for a resource a newer
|
||||||
|
// release is backfilling is a no-op.
|
||||||
|
|
||||||
|
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
|
||||||
|
// host -> host; service -> app (services/consoles are the group model's "apps");
|
||||||
|
// site gets site-level groups (handled separately); oauth/container get no
|
||||||
|
// per-resource groups (oauth clients hang off their owning service).
|
||||||
|
function groupKind(resource) {
|
||||||
|
if (resource.kind === 'host') return 'host';
|
||||||
|
if (resource.kind === 'service') return 'app';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
|
||||||
|
// seeds the mandatory first member. Returns true when created.
|
||||||
|
async function ensureGroup(name, ownerDn, description) {
|
||||||
|
try {
|
||||||
|
await Group.add({ name, owner: ownerDn, description });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||||
|
console.error(`ensureGroup: failed to create ${name}:`, err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link a group to a resource only if that link doesn't already exist. The
|
||||||
|
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
|
||||||
|
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||||
|
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||||
|
// "groups appear 3x under a resource" bug. Always check first.
|
||||||
|
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||||
|
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||||
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
|
// older release gets its groups without a rebuild:
|
||||||
|
//
|
||||||
|
// god_admin -> {site}_super_admin
|
||||||
|
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
|
||||||
|
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
|
||||||
|
//
|
||||||
|
// `{site}_everyone` is created for completeness; it has implicit membership and
|
||||||
|
// is granted to a resource as a grantee, never enumerated.
|
||||||
|
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
||||||
|
if (!siteSlug) return;
|
||||||
|
|
||||||
|
// Link a site group to the site resource (so it shows + is member-manageable
|
||||||
|
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
|
||||||
|
// groups as member.
|
||||||
|
const link = async (cn, isAdmin) => {
|
||||||
|
if (!siteResourceId) return;
|
||||||
|
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
|
await link(sAdmin, true);
|
||||||
|
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
|
||||||
|
// them), but are NOT linked to the site resource: a site carries only the god
|
||||||
|
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
|
||||||
|
// aggregates have no modal home; site-wide access is granted via S_super_admin
|
||||||
|
// and per-resource access via the host/app groups.
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
|
}
|
||||||
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
|
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||||
|
// god_admin is the global group; surface it on the site modal so its members
|
||||||
|
// can be managed from the Directory (it has no home on a single resource).
|
||||||
|
await link(groups.GOD_ADMIN, true);
|
||||||
|
|
||||||
|
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
|
||||||
|
// binding directly) resolve it transitively, not just utils/permission.js.
|
||||||
|
// nestGroup(child, parent) makes child a member of parent -- membership flows
|
||||||
|
// child -> parent ("up"), so a group's members inherit what its parents hold.
|
||||||
|
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
|
||||||
|
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the per-resource groups for a host/app and nest them into the site
|
||||||
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
|
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
|
||||||
|
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
|
||||||
|
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
|
||||||
|
//
|
||||||
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
|
||||||
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
|
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
|
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
|
||||||
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
|
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
|
||||||
|
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
|
||||||
|
|
||||||
|
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
|
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||||
|
|
||||||
|
// Link both groups to the resource so the Directory can show/revoke them.
|
||||||
|
await ensureResourceGroup(resource.id, accessCn, 'member');
|
||||||
|
await ensureResourceGroup(resource.id, adminCn, 'owner');
|
||||||
|
|
||||||
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
|
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||||
|
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||||
|
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||||
|
// §2/§3). This is what "force the correct naming convention" means: a group
|
||||||
|
// linked to a resource must be one that parses for consumers -- the resource's
|
||||||
|
// own specific groups, its site's aggregates, site-level groups, or the global
|
||||||
|
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
|
||||||
|
// capability groups following the same shapes.
|
||||||
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
|
const valid = new Set();
|
||||||
|
// A site resource only carries god_admin (added by the route) + the site-wide
|
||||||
|
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
|
||||||
|
// specific groups belong to host/app resources, not to the site.
|
||||||
|
if (resource.kind === 'site') {
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
|
||||||
|
}
|
||||||
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
|
if (kind) {
|
||||||
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
return { valid, capRe: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -44,12 +199,43 @@ router.get('/resources', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
let resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
resources = resources.filter(r => {
|
resources = resources.filter(r => {
|
||||||
|
if (r.kind === 'host' || r.kind === 'site') return true;
|
||||||
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
const isManaged = r.metadata?.managed === true;
|
const isManaged = r.metadata?.managed === true;
|
||||||
return !isAuto || isManaged;
|
return !isAuto || isManaged;
|
||||||
});
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
|
||||||
|
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||||
|
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||||
|
// aggregates, and every host/app resource has its per-resource groups nested
|
||||||
|
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||||
|
// backfills a directory seeded by an older release without a rebuild.
|
||||||
|
// Never fails the list.
|
||||||
|
const sites = resources.filter(r => r.kind === 'site');
|
||||||
|
await Promise.all(sites.map(site =>
|
||||||
|
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||||
|
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||||
|
));
|
||||||
|
const siteByResource = new Map();
|
||||||
|
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||||
|
const siteOf = async (r) => {
|
||||||
|
const direct = siteByResource.get(r.id);
|
||||||
|
if (direct) return direct;
|
||||||
|
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||||
|
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||||
|
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||||
|
};
|
||||||
|
await Promise.all(resources.map(async (r) => {
|
||||||
|
const gKind = groupKind(r);
|
||||||
|
if (!gKind) return;
|
||||||
|
const siteSlug = await siteOf(r);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -95,46 +281,25 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
// Materialize the group-model for the new resource. Site resources get the
|
||||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
// site-level groups; host/app resources get their per-resource groups nested
|
||||||
|
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
// older release. A provisioning failure must not fail resource creation: the
|
||||||
const cn = groupCn(suffix);
|
// resource already exists and the groups are repairable (re-run ensures them).
|
||||||
try {
|
//
|
||||||
await Group.add({
|
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||||
name: cn,
|
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||||
owner: req.user.dn,
|
// the kind prefix themselves.
|
||||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
const gKind = groupKind(r);
|
||||||
});
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
} catch (err) {
|
if (r.kind === 'site') {
|
||||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
} else if (gKind && ancestorSite) {
|
||||||
}
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
}
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
try {
|
|
||||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
|
||||||
} catch(err) { /* ignore duplicate links */ }
|
|
||||||
};
|
|
||||||
await createGroup('access', 'member');
|
|
||||||
await createGroup('admin', 'owner');
|
|
||||||
|
|
||||||
// Wire up the two standing relationships every resource has, as nesting
|
|
||||||
// rather than as membership that has to be maintained per resource:
|
|
||||||
//
|
|
||||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
|
||||||
// every resource, automatically
|
|
||||||
// <slug>_admin -> <slug>_access administering something implies
|
|
||||||
// being able to use it
|
|
||||||
//
|
|
||||||
// Before nesting, both of these could only be expressed by adding every
|
|
||||||
// super admin to every new group by hand -- which nobody does, so the
|
|
||||||
// groups drifted. A failure here must not fail resource creation: the
|
|
||||||
// resource and its groups already exist and the nesting is repairable.
|
|
||||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
|
||||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
@@ -265,7 +430,29 @@ router.get('/groups', async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/groups', async (req, res, next) => {
|
router.post('/groups', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const g = await ResourceGroup.create(req.body);
|
const { resourceId, groupCn } = req.body;
|
||||||
|
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
|
||||||
|
|
||||||
|
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
|
||||||
|
// be a valid group for this resource; reject free-form names so the groups
|
||||||
|
// consumers read are always parseable. god_admin is always allowed (it is
|
||||||
|
// the global group and is managed from a site's modal).
|
||||||
|
const resource = await Resource.get(resourceId);
|
||||||
|
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
|
||||||
|
// site resource's own slug is its site; a host/app uses its ancestor site.
|
||||||
|
const siteSlug = resource && resource.kind === 'site'
|
||||||
|
? resource.slug
|
||||||
|
: await Resource.findAncestorSiteSlug(resourceId);
|
||||||
|
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
|
||||||
|
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
|
||||||
|
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
|
||||||
|
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
|
||||||
res.json({ results: g });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -313,7 +500,7 @@ router.get('/access-summary', async (req, res, next) => {
|
|||||||
//
|
//
|
||||||
// Counts come from the transitive closure, not from `member`. Reading the
|
// Counts come from the transitive closure, not from `member`. Reading the
|
||||||
// attribute would report only who is listed on the group, missing anyone
|
// attribute would report only who is listed on the group, missing anyone
|
||||||
// who reaches it through a nested group -- and since app_super_admin is
|
// who reaches it through a nested group -- and since god_admin is
|
||||||
// nested into every resource's _admin group, that is not an edge case.
|
// nested into every resource's _admin group, that is not an edge case.
|
||||||
let members = [];
|
let members = [];
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -414,4 +601,140 @@ router.get('/audit-logs', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
|
||||||
|
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
router.get('/resources/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
// Read resource secrets from OpenBao
|
||||||
|
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||||
|
const r = await baoConf.request('GET', path);
|
||||||
|
let secretsMap = {};
|
||||||
|
if (r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
secretsMap = (body.data && body.data.data) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-View Security: Return metadata only, NEVER return raw secret values
|
||||||
|
const secrets = Object.keys(secretsMap).map(key => {
|
||||||
|
const val = String(secretsMap[key] || '');
|
||||||
|
let isInherited = false;
|
||||||
|
let parentSlug = null;
|
||||||
|
let parentKey = null;
|
||||||
|
|
||||||
|
if (val.startsWith('INHERIT:')) {
|
||||||
|
isInherited = true;
|
||||||
|
const parts = val.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
parentSlug = parts[1];
|
||||||
|
parentKey = parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
parentKey = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
hasValue: val.length > 0,
|
||||||
|
isInherited,
|
||||||
|
parentSlug,
|
||||||
|
parentKey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites
|
||||||
|
const parentSecrets = [];
|
||||||
|
const seenAncestors = new Set();
|
||||||
|
|
||||||
|
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
|
||||||
|
const allAncestors = [...ancestors, ...sites];
|
||||||
|
|
||||||
|
for (const parent of allAncestors) {
|
||||||
|
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
|
||||||
|
seenAncestors.add(parent.id);
|
||||||
|
|
||||||
|
const parentPath = `secret/data/resources/${parent.slug}/conf`;
|
||||||
|
const parentR = await baoConf.request('GET', parentPath);
|
||||||
|
if (parentR.ok) {
|
||||||
|
const parentBody = await parentR.json().catch(() => ({}));
|
||||||
|
const pMap = (parentBody.data && parentBody.data.data) || {};
|
||||||
|
for (const pKey of Object.keys(pMap)) {
|
||||||
|
parentSecrets.push({
|
||||||
|
parentSlug: parent.slug,
|
||||||
|
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
|
||||||
|
key: pKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {};
|
||||||
|
|
||||||
|
// Validate key names (Standard Env Var format: A-Z, 0-9, underscores)
|
||||||
|
for (const key of Object.keys(secrets)) {
|
||||||
|
if (!SECRET_KEY_REGEX.test(key)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||||
|
const r = await baoConf.request('POST', path, { data: secrets });
|
||||||
|
if (!r.ok) {
|
||||||
|
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
|
||||||
|
}
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/resources/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
|
||||||
|
const sharedSecretIds = grants.map(g => g.secretId);
|
||||||
|
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
|
||||||
|
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const { secretSlug, action } = req.body || {};
|
||||||
|
const secret = await SharedSecret.getBySlug(secretSlug);
|
||||||
|
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
|
||||||
|
|
||||||
|
if (action === 'revoke') {
|
||||||
|
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
|
||||||
|
for (const g of existing) await g.delete();
|
||||||
|
return res.json({ status: 'ok', message: 'grant revoked' });
|
||||||
|
} else {
|
||||||
|
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
|
||||||
|
return res.json({ status: 'ok', message: 'grant created' });
|
||||||
|
}
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3).
|
||||||
|
//
|
||||||
|
// The whole point of this API is that a client stops speaking LDAP and instead
|
||||||
|
// does an HTTPS call to the SSO, where the directory is reachable. That kills
|
||||||
|
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
|
||||||
|
// cert to trust, no firewall rule.
|
||||||
|
//
|
||||||
|
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
|
||||||
|
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
|
||||||
|
//
|
||||||
|
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
|
||||||
|
// are accepted, reusing existing credentials:
|
||||||
|
// - an agent token (the same one the agent presents on its WSS channel) — the
|
||||||
|
// caller is a node acting for SSSD;
|
||||||
|
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
|
||||||
|
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
|
||||||
|
//
|
||||||
|
// Security note on /search: it runs under the directory admin bind (withClient),
|
||||||
|
// so it can read the whole tree. It is therefore restricted to agent callers
|
||||||
|
// (the SSSD user/group-resolution use case) and must eventually move to a
|
||||||
|
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const { ApiToken } = require('../models/api_token');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const ldap = createLdapClient(conf);
|
||||||
|
|
||||||
|
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
|
||||||
|
// first, then a PAT. Every failure collapses to null so a probing caller learns
|
||||||
|
// nothing about which credential was wrong.
|
||||||
|
async function authenticateCaller(req) {
|
||||||
|
const auth = req.headers['authorization'] || '';
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||||
|
if (!m) return null;
|
||||||
|
const token = String(m[1]).trim();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await Agent.authenticate(token);
|
||||||
|
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pat = await ApiToken.authenticate(token);
|
||||||
|
if (pat) return { kind: 'user', id: pat.created_by };
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /bind — authenticate a username/password against the directory.
|
||||||
|
router.post('/bind', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const caller = await authenticateCaller(req);
|
||||||
|
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'username and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the username to a DN, then simple-bind as that DN. A missing user
|
||||||
|
// and a wrong password both surface as 401 (no user-existence oracle).
|
||||||
|
const user = await ldap.getUser(String(username));
|
||||||
|
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||||
|
|
||||||
|
const ok = await ldap.checkPassword(user.dn, String(password));
|
||||||
|
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /search — run a directory search. Agent callers only (see header note).
|
||||||
|
router.post('/search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const caller = await authenticateCaller(req);
|
||||||
|
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
if (caller.kind !== 'agent') {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { base_dn, scope, filter, attributes } = req.body || {};
|
||||||
|
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
|
||||||
|
|
||||||
|
const entries = await ldap.withClient(async (client) => {
|
||||||
|
const { searchEntries } = await client.search(base_dn || conf.userBase, {
|
||||||
|
scope: scope || 'sub',
|
||||||
|
filter: String(filter),
|
||||||
|
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
|
||||||
|
});
|
||||||
|
return searchEntries;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', entries });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Shared-secrets API.
|
||||||
|
//
|
||||||
|
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
|
||||||
|
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
|
||||||
|
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
|
||||||
|
// OpenBao policy content is edited to add read on the exact shared path (see
|
||||||
|
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
|
||||||
|
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
|
||||||
|
// immediately, with no token re-mint.
|
||||||
|
//
|
||||||
|
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
|
||||||
|
// them through the existing /api/vault proxy using the requester's own session
|
||||||
|
// token, so OpenBao ACL enforces read access per-request. This router handles
|
||||||
|
// metadata CRUD + grant management; KV writes (create/update/delete) are made
|
||||||
|
// server-side using the acting user's scoped token.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
|
||||||
|
// only reject values that can't be a sane secret path segment (spaces, slashes,
|
||||||
|
// leading non-alnum, too long).
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
|
||||||
|
// /api/vault proxy — personal, per-user secret management only).
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (req.user && req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
|
||||||
|
catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
|
||||||
|
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
|
||||||
|
// sso-admin token (R/W on secret/*).
|
||||||
|
async function actorToken(user, ownerUid) {
|
||||||
|
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
|
||||||
|
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does this user manage the given shared secret? Owner or admin.
|
||||||
|
async function canManage(user, secret) {
|
||||||
|
if (user.uid === secret.ownerUid) return true;
|
||||||
|
return isAdmin(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecret(req, res) {
|
||||||
|
const secret = await SharedSecret.get(req.params.id);
|
||||||
|
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List: mine + shared-with-me ─────────────────────────────────────────────
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('user', uid);
|
||||||
|
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const granted = granteeSecretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
|
||||||
|
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
|
||||||
|
for (const g of granted) {
|
||||||
|
if (byId.has(g.id)) continue; // already owner
|
||||||
|
byId.set(g.id, { role: 'grantee', ...g });
|
||||||
|
}
|
||||||
|
// The `{ role, ...s }` spread above copies only own properties, so the
|
||||||
|
// instance method `path()` is dropped -- call the static builder instead.
|
||||||
|
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const slug = String(req.body.slug || '').trim().toLowerCase();
|
||||||
|
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
|
||||||
|
const description = String(req.body.description || '').trim();
|
||||||
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||||
|
|
||||||
|
if (await SharedSecret.getBySlug(slug)) {
|
||||||
|
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
|
||||||
|
}
|
||||||
|
const token = await actorToken(req.user, uid);
|
||||||
|
if (!token) return res.status(403).json({ error: 'not allowed' });
|
||||||
|
const path = SharedSecret.pathFor(uid, slug);
|
||||||
|
await baoConf.set(path, data, { token });
|
||||||
|
|
||||||
|
const secret = await SharedSecret.create({
|
||||||
|
slug, ownerUid: uid, description,
|
||||||
|
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
|
||||||
|
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update data / description ───────────────────────────────────────────────
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
const update = {};
|
||||||
|
if (req.body && typeof req.body.data === 'object') {
|
||||||
|
await baoConf.set(secret.path(), req.body.data, { token });
|
||||||
|
}
|
||||||
|
if (req.body && req.body.description !== undefined) {
|
||||||
|
update.description = String(req.body.description).trim();
|
||||||
|
}
|
||||||
|
if (Object.keys(update).length) {
|
||||||
|
update.updated_by = req.user.uid;
|
||||||
|
update.updated_on = Date.now();
|
||||||
|
await secret.update(update);
|
||||||
|
}
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
// Revoke all grants first so grantees' policies drop the path.
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
|
||||||
|
// Delete the KV data (metadata delete removes all versions), then the row.
|
||||||
|
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
|
||||||
|
await secret.delete();
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: list ────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: create ──────────────────────────────────────────────────────────
|
||||||
|
router.post('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const granteeType = String(req.body.granteeType || '').trim();
|
||||||
|
const granteeId = String(req.body.granteeId || '').trim();
|
||||||
|
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
|
||||||
|
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
|
||||||
|
if (granteeId === secret.ownerUid && granteeType === 'user') {
|
||||||
|
return res.status(400).json({ error: 'the owner already has access' });
|
||||||
|
}
|
||||||
|
// Idempotent: skip if the grant already exists.
|
||||||
|
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
|
||||||
|
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
|
||||||
|
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
|
||||||
|
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: revoke ──────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id/grants/:grantId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grant = await SharedSecretGrant.get(req.params.grantId);
|
||||||
|
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
|
||||||
|
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
meta.managed = true;
|
||||||
await resource.update({ metadata: meta });
|
// `Resource.update` is not a static — `update` is an instance method
|
||||||
|
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
|
||||||
|
const inst = await Resource.get(resource.id);
|
||||||
|
await inst.update({ metadata: meta });
|
||||||
|
|
||||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
|
||||||
|
// guide the Directory links to -- was unreachable in the app.
|
||||||
|
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
|
||||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||||
|
// The Discovery tab's help icon links here; without an entry it 404'd.
|
||||||
|
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
@@ -54,7 +59,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
|
|||||||
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
||||||
// the rendered markup to point at it absolutely, so the images work when
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
// read from /docs/overview too.
|
// read from /docs/overview too.
|
||||||
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
||||||
function fixImagePaths(html) {
|
function fixImagePaths(html) {
|
||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/plugins', function(req, res, next) {
|
router.get('/plugins', function(req, res, next) {
|
||||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
res.redirect('/directory');
|
||||||
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
|
|
||||||
// client gates with app.auth.forceLogin(['app_sso_admin',
|
|
||||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
|
||||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
|
||||||
// /vault (auth-token is a client-set header, not a cookie).
|
|
||||||
const registry = require('../services/plugin_registry');
|
|
||||||
res.render('plugins', {...values, pluginTypes: registry.types });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/vault', function(req, res) {
|
router.get('/vault', function(req, res) {
|
||||||
@@ -195,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) {
|
|||||||
res.render('profile', {...values});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/groups', function(req, res, next) {
|
|
||||||
res.render('groups', {...values});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/token', function(req, res, next) {
|
router.get('/token', function(req, res, next) {
|
||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
|
|||||||
// same answer in both modes.
|
// same answer in both modes.
|
||||||
const groups = await groupCns(user);
|
const groups = await groupCns(user);
|
||||||
user.groups = groups;
|
user.groups = groups;
|
||||||
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
|
||||||
|
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
|
||||||
|
// legacy app_sso_admin/app_super_admin during migration.
|
||||||
|
user.isAdmin = groups.some((g) =>
|
||||||
|
g === 'app_sso_admin' || g === 'app_super_admin' ||
|
||||||
|
g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP ||
|
||||||
|
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
|
||||||
|
|
||||||
return res.json(user);
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -2,42 +2,87 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
|||||||
const { WebhookEmitter } = require('./webhook_emitter');
|
const { WebhookEmitter } = require('./webhook_emitter');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
|
||||||
|
// edge that would close a loop. Carries its own visited set so it terminates
|
||||||
|
// even if the stored graph already contains a cycle from an older release.
|
||||||
|
function isDescendant(candidateId, rootId, edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (id === candidateId) return true;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
class DiscoveryReconciler {
|
class DiscoveryReconciler {
|
||||||
static async reconcile(sourceName, payload) {
|
static async reconcile(sourceName, payload) {
|
||||||
const { resources = [], edges = [] } = payload;
|
const { resources = [], edges = [] } = payload;
|
||||||
let newDevices = 0;
|
let newDevices = 0;
|
||||||
|
|
||||||
|
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||||
|
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||||
|
|
||||||
|
// Read the inventory ONCE, not once per incoming resource. A Proxmox
|
||||||
|
// cluster reports ~55 resources against an inventory of similar size, so
|
||||||
|
// the per-iteration Resource.list() was doing quadratic full-table reads
|
||||||
|
// every discovery run. Newly created rows are pushed onto this list as we
|
||||||
|
// go, so later resources in the same payload still match against them.
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
|
||||||
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
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
let existing = null;
|
let existing = null;
|
||||||
|
|
||||||
// Attempt matching by MAC if available (case-insensitive)
|
// A discovered device may only merge into a resource of the same kind
|
||||||
|
// (or into a placeholder from an earlier, kind-less discovery). Without
|
||||||
|
// this a VM called "gitea-runner" matches a hand-created *service* of
|
||||||
|
// the same name on rule 3 and silently overwrites it -- the discovered
|
||||||
|
// host's metadata lands on a service row, and the operator's entry is
|
||||||
|
// gone. `template` counts as `host`: a VM converted to a template is the
|
||||||
|
// same device, and it should update in place rather than fork a row.
|
||||||
|
const kindClass = (k) => (k === 'template' ? 'host' : k);
|
||||||
|
const incomingKind = kindClass(res.kind || 'unmanaged_device');
|
||||||
|
const kindCompatible = (r) => {
|
||||||
|
const k = kindClass(r.kind);
|
||||||
|
if (k === 'unmanaged_device' || incomingKind === 'unmanaged_device') return true;
|
||||||
|
return k === incomingKind;
|
||||||
|
};
|
||||||
|
const candidates = allRes.filter(kindCompatible);
|
||||||
|
|
||||||
|
// 1. Attempt matching by MAC (highest precision)
|
||||||
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 ? i.mac.toLowerCase() : null).filter(m => !!m);
|
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||||
if (macs.length > 0) {
|
if (macs.length > 0) {
|
||||||
const allRes = await Resource.list();
|
existing = candidates.find(r =>
|
||||||
existing = allRes.find(r =>
|
r.metadata && (
|
||||||
r.metadata && r.metadata.interfaces &&
|
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||||
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
|
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback matching by IP if no MAC match (weaker)
|
// 2. Fallback matching by IP address
|
||||||
let ipsToMatch = [];
|
let ipsToMatch = [];
|
||||||
if (res.metadata.interfaces) {
|
if (res.metadata.interfaces) {
|
||||||
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||||
}
|
}
|
||||||
|
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||||
if (res.metadata.address) {
|
if (res.metadata.address) {
|
||||||
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||||
}
|
}
|
||||||
|
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||||
|
|
||||||
if (!existing && ipsToMatch.length > 0) {
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
const allRes = await Resource.list();
|
existing = candidates.find(r => {
|
||||||
existing = allRes.find(r => {
|
|
||||||
if (!r.metadata) return false;
|
if (!r.metadata) return false;
|
||||||
|
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||||
if (r.metadata.address) {
|
if (r.metadata.address) {
|
||||||
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||||
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||||
@@ -46,14 +91,17 @@ class DiscoveryReconciler {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback matching by Slug or Name
|
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||||
if (!existing && (res.slug || res.name)) {
|
if (!existing && (res.slug || res.name)) {
|
||||||
const allRes = await Resource.list();
|
const inputName = normalizeHost(res.name || res.slug);
|
||||||
existing = allRes.find(r =>
|
existing = candidates.find(r => {
|
||||||
(res.slug && r.slug === res.slug) ||
|
if (res.slug && r.slug === res.slug) return true;
|
||||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
|
||||||
);
|
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
|
||||||
|
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -83,10 +131,33 @@ 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 || '');
|
// Pick the most human name across sources. Rank first, length only as
|
||||||
|
// a tie-break within a rank -- comparing lengths alone let a UniFi
|
||||||
|
// client named after its MAC ("ac:16:2d:b3:da:80", 17 chars) beat the
|
||||||
|
// hypervisor's real hostname from Proxmox ("dl380-0", 7), so the
|
||||||
|
// Directory listed MAC addresses where host names belong.
|
||||||
|
//
|
||||||
|
// NB: `\\.` inside a regex LITERAL matches a backslash, not a dot, so
|
||||||
|
// the old isIp returned false for every input and IP-shaped names were
|
||||||
|
// never replaced either. It is `\.` here.
|
||||||
|
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
|
||||||
|
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
|
||||||
|
// 2 = a real name, 1 = an IP (at least routable/recognizable), 0 = a
|
||||||
|
// MAC or nothing (pure machine identifier, the worst thing to show).
|
||||||
|
const nameRank = (str) => {
|
||||||
|
if (!str || !String(str).trim()) return 0;
|
||||||
|
if (isMac(str)) return 0;
|
||||||
|
if (isIp(str)) return 1;
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
|
||||||
let bestName = existing.name;
|
let bestName = existing.name;
|
||||||
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
|
if (res.name) {
|
||||||
bestName = res.name;
|
const incoming = nameRank(res.name);
|
||||||
|
const current = nameRank(bestName);
|
||||||
|
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
|
||||||
|
bestName = res.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await existing.update({
|
await existing.update({
|
||||||
@@ -115,12 +186,17 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
newDevices++;
|
newDevices++;
|
||||||
res._actualId = created.id; // Map original slug to actual ID
|
res._actualId = created.id; // Map original slug to actual ID
|
||||||
|
// Make it visible to the rest of THIS payload: a Proxmox run reports
|
||||||
|
// the endpoint, then its nodes, then their guests, and two of them can
|
||||||
|
// legitimately share a MAC/IP. Without this the same device could be
|
||||||
|
// created twice in a single run.
|
||||||
|
allRes.push(created);
|
||||||
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now process edges
|
// Now process edges. `allRes` above is already current -- rows created in
|
||||||
const allRes = await Resource.list();
|
// the loop were pushed onto it -- so no second full read is needed.
|
||||||
const existingEdges = await ResourceEdge.list();
|
const existingEdges = await ResourceEdge.list();
|
||||||
|
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
@@ -144,15 +220,37 @@ class DiscoveryReconciler {
|
|||||||
if (childResInDb) childId = childResInDb.id;
|
if (childResInDb) childId = childResInDb.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two slugs in one payload can resolve to the SAME resource once the
|
||||||
|
// matcher has merged them -- a Proxmox endpoint reached at the address
|
||||||
|
// of the node that answers for it is the case that produced this. The
|
||||||
|
// edge would then make a resource its own parent, which renders as an
|
||||||
|
// infinitely nested tree and defeats every ancestor walk in the app
|
||||||
|
// (findAncestorSiteSlug, withResolvedAddress) that relies on a cycle
|
||||||
|
// guard to terminate rather than to be correct.
|
||||||
|
if (parentId && childId && parentId === childId) {
|
||||||
|
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping self-edge on ${edge.parentSlug} -> ${edge.childSlug} (both resolved to the same resource)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Likewise refuse an edge that closes a loop: if the proposed parent is
|
||||||
|
// already a descendant of the proposed child, adding this makes a cycle.
|
||||||
|
if (parentId && childId && isDescendant(parentId, childId, existingEdges)) {
|
||||||
|
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping ${edge.parentSlug} -> ${edge.childSlug} (would create a cycle)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (parentId && childId) {
|
if (parentId && childId) {
|
||||||
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||||
if (!edgeExists) {
|
if (!edgeExists) {
|
||||||
await ResourceEdge.create({
|
const created = await ResourceEdge.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
parentId,
|
parentId,
|
||||||
childId,
|
childId,
|
||||||
relation: edge.relation
|
relation: edge.relation
|
||||||
});
|
});
|
||||||
|
// Keep the in-memory edge list current so the cycle check above sees
|
||||||
|
// edges added earlier in this same payload.
|
||||||
|
existingEdges.push(created);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
||||||
const app = express();
|
|
||||||
app.use('/', createProxyMiddleware({
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
on: {
|
|
||||||
proxyRes: (proxyRes, req, res) => {
|
|
||||||
delete proxyRes.headers['x-frame-options'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
app.listen(3004);
|
|
||||||
@@ -44,9 +44,10 @@ beforeAll(async () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
hostId = host.body.results.id;
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||||
|
|
||||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||||
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||||
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
// Same as the top-level setup: step out of the auto-created groups the
|
// Same as the top-level setup: step out of the auto-created groups the
|
||||||
// creator is seeded into, or this is a request for access already held.
|
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
// or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||||
await request(app)
|
await request(app)
|
||||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
.set('auth-token', token);
|
.set('auth-token', token);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// In-memory stand-in for OpenBao. The signing key lives at secret/agent/
|
||||||
|
// signing-key in production; here we only need it to persist across calls so
|
||||||
|
// the "same key every time" property is actually exercised rather than mocked
|
||||||
|
// away.
|
||||||
|
const mockBaoStore = new Map();
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||||
|
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); }),
|
||||||
|
request: jest.fn(async () => ({ ok: true, status: 200 }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const agentManager = require('../utils/agent_manager');
|
||||||
|
const agentKeys = require('../utils/agent_keys');
|
||||||
|
|
||||||
|
// The manager is now keyed by enrolled Agent rows rather than by a bare token
|
||||||
|
// string, so these use a stub row with the same surface the real model gives:
|
||||||
|
// an id, and an update() that records what would be persisted.
|
||||||
|
function stubAgent(overrides = {}) {
|
||||||
|
const row = {
|
||||||
|
id: overrides.id || crypto.randomUUID(),
|
||||||
|
name: overrides.name || 'test-agent',
|
||||||
|
resourceId: overrides.resourceId || null,
|
||||||
|
revoked: false,
|
||||||
|
persisted: {},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
row.update = jest.fn(async (patch) => {
|
||||||
|
Object.assign(row.persisted, patch);
|
||||||
|
Object.assign(row, patch);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentManager PROTOCOL.md v1.2.0 Compliance', () => {
|
||||||
|
let mockWs;
|
||||||
|
let sentMessages;
|
||||||
|
let agent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sentMessages = [];
|
||||||
|
mockWs = {
|
||||||
|
readyState: 1, // OPEN
|
||||||
|
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
|
||||||
|
close: jest.fn()
|
||||||
|
};
|
||||||
|
agent = stubAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers an agent and reports it as connected', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
const state = agentManager.liveState(agent.id);
|
||||||
|
expect(state.connected).toBe(true);
|
||||||
|
expect(state.ipAddress).toBe('192.168.1.100');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// registerAgent must not be async: the WS `message` listener is attached in
|
||||||
|
// the same tick, and `ws` drops events emitted before a listener exists. An
|
||||||
|
// awaited DB write here swallowed every agent's first discovery frame, which
|
||||||
|
// is the one it sends immediately on connect.
|
||||||
|
test('registerAgent is synchronous so no message can be missed', () => {
|
||||||
|
const result = agentManager.registerAgent(agent, mockWs, '10.0.0.1');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists discovery to the agent row (Section 3.1)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleDiscovery(agent, {
|
||||||
|
hostname: 'node-01.local',
|
||||||
|
ip_addresses: ['192.168.1.100', '10.0.0.5'],
|
||||||
|
os: 'Ubuntu 24.04 LTS',
|
||||||
|
kernel: '6.8.0-31-generic',
|
||||||
|
cpu: 'AMD EPYC 7763',
|
||||||
|
ram_total_gb: 32.0,
|
||||||
|
disk_total_gb: 500.0,
|
||||||
|
location: 'dc-chicago-rack-4'
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = agent.persisted.lastDiscovery;
|
||||||
|
expect(saved.hostname).toBe('node-01.local');
|
||||||
|
expect(saved.os).toBe('Ubuntu 24.04 LTS');
|
||||||
|
expect(saved.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
|
||||||
|
// Durable, not just in memory: an agent that goes offline keeps its facts.
|
||||||
|
expect(agent.persisted.last_seen).toEqual(expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists telemetry to the agent row (Section 3.2)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleTelemetry(agent, {
|
||||||
|
cpu_usage_percent: 14.5,
|
||||||
|
ram_usage_percent: 42.1,
|
||||||
|
disk_usage_percent: 68.0,
|
||||||
|
zfs_health: 'ONLINE',
|
||||||
|
gpu_usage_percent: -1.0,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.persisted.lastTelemetry.cpu_usage_percent).toBe(14.5);
|
||||||
|
expect(agent.persisted.lastTelemetry.zfs_health).toBe('ONLINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('responds to heartbeat with heartbeat_ack (Section 3.3)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleHeartbeat(agent, { timestamp: new Date().toISOString() }, mockWs);
|
||||||
|
|
||||||
|
expect(mockWs.send).toHaveBeenCalled();
|
||||||
|
const lastMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(lastMsg.type).toBe('heartbeat_ack');
|
||||||
|
expect(lastMsg.payload.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonicalizes and signs high-risk commands with Ed25519 (Section 5)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
|
||||||
|
const rawPayload = { script: 'uptime', location: 'datacenter' };
|
||||||
|
const msg = await agentManager.sendCommand(agent, 'arbitrary_bash', rawPayload, true);
|
||||||
|
|
||||||
|
expect(msg.type).toBe('arbitrary_bash');
|
||||||
|
expect(typeof msg.payload.signature).toBe('string');
|
||||||
|
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
const isValid = crypto.verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(agentManager.canonicalize(rawPayload), 'utf8'),
|
||||||
|
crypto.createPublicKey(keys.publicKeyPem),
|
||||||
|
Buffer.from(msg.payload.signature, 'base64')
|
||||||
|
);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The canonical form has to match the Go agent's byte for byte. Go's
|
||||||
|
// encoding/json escapes <, > and & by default and JSON.stringify does not, so
|
||||||
|
// the agent uses SetEscapeHTML(false); this pins the server's half of that
|
||||||
|
// contract. See theta-agent TestCanonicalizeMatchesServerForm.
|
||||||
|
test('canonical form is sorted, unescaped, and omits the signature', () => {
|
||||||
|
const canonical = agentManager.canonicalize({
|
||||||
|
script: 'echo a > b && c',
|
||||||
|
comment: 'x&y',
|
||||||
|
signature: 'should-not-appear'
|
||||||
|
});
|
||||||
|
expect(canonical).toBe('{"comment":"x&y","script":"echo a > b && c"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to send to an agent that is not connected', async () => {
|
||||||
|
await expect(agentManager.sendCommand(agent, 'reload_config', {}, false))
|
||||||
|
.rejects.toThrow(/not connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revocation that only applies on the next reconnect is not revocation.
|
||||||
|
test('disconnect drops the live socket immediately', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
|
||||||
|
const dropped = agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
|
||||||
|
expect(dropped).toBe(true);
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4003, 'Enrollment revoked');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second connection for the same agent supersedes the first', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
const secondWs = { readyState: 1, send: jest.fn(), close: jest.fn() };
|
||||||
|
agentManager.registerAgent(agent, secondWs, '192.168.1.101');
|
||||||
|
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4002, 'Superseded by new connection');
|
||||||
|
expect(agentManager.liveState(agent.id).ipAddress).toBe('192.168.1.101');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown agent id is simply not connected', () => {
|
||||||
|
expect(agentManager.isConnected('no-such-agent')).toBe(false);
|
||||||
|
expect(agentManager.liveState('no-such-agent')).toEqual({ connected: false, lastResponse: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agent signing key', () => {
|
||||||
|
// The old manager generated a key pair in its constructor, so it changed on
|
||||||
|
// every restart and the public_key pinned in agent.yml stopped matching.
|
||||||
|
test('the same key is returned across repeated loads', async () => {
|
||||||
|
const first = await agentKeys.load();
|
||||||
|
const second = await agentKeys.load();
|
||||||
|
expect(first.publicKeyBase64).toBe(second.publicKeyBase64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the exported public key is the raw 32 bytes agents pin', async () => {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
expect(Buffer.from(keys.publicKeyBase64, 'base64')).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rawPublicKeyBase64 strips the SPKI wrapper', () => {
|
||||||
|
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
||||||
|
});
|
||||||
|
const raw = Buffer.from(agentKeys.rawPublicKeyBase64(publicKey), 'base64');
|
||||||
|
expect(raw).toHaveLength(32);
|
||||||
|
// and it is the tail of the DER encoding
|
||||||
|
const der = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
|
||||||
|
expect(raw.equals(der.subarray(der.length - 32))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Agent-facing ops (DESIGN.md §5): node-scoped secrets. OpenBao is not present
|
||||||
|
// in the test env, so @simpleworkjs/bao-conf is mocked.
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
request: jest.fn(async (method, path) => {
|
||||||
|
if (path.startsWith('secret/data/nodes/')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { data: { username: 'alice', password: 's3cret' } } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { request, app } = require('./setup');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
|
||||||
|
async function enrollAgent() {
|
||||||
|
const { agent, token } = await Agent.enroll({
|
||||||
|
name: `ops-test-${Date.now().toString(36)}`,
|
||||||
|
description: 'api_agent_ops test',
|
||||||
|
enrolledBy: 'test'
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Agent ops — POST /api/v1/agent/secrets', () => {
|
||||||
|
test('an agent can fetch its own node-scoped secrets', async () => {
|
||||||
|
const { agent, token } = await enrollAgent();
|
||||||
|
const path = `secret/data/nodes/${agent.id}/db`;
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ paths: [path] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.secrets[path]).toEqual({ username: 'alice', password: 's3cret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a path outside the node scope is rejected', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ paths: ['secret/data/nodes/other-node/db'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no bearer token returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.send({ paths: ['secret/data/nodes/x/db'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing paths returns 400', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
|
||||||
|
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
|
||||||
|
// restriction.
|
||||||
|
|
||||||
|
const { TEST_CREDS, request, app } = require('./setup');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const { ApiToken } = require('../models/api_token');
|
||||||
|
|
||||||
|
async function enrollAgent() {
|
||||||
|
const { agent, token } = await Agent.enroll({
|
||||||
|
name: `ldap-test-${Date.now().toString(36)}`,
|
||||||
|
description: 'api_ldap test agent',
|
||||||
|
enrolledBy: 'test'
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePat() {
|
||||||
|
const token = await ApiToken.add({
|
||||||
|
name: 'ldap-test-pat',
|
||||||
|
description: 'api_ldap test',
|
||||||
|
created_by: 'test'
|
||||||
|
});
|
||||||
|
return token._raw_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
|
||||||
|
test('valid credentials return the bound DN', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.uid).toBe(TEST_CREDS.uid);
|
||||||
|
expect(res.body.dn).toContain(TEST_CREDS.uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong password returns 401', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown user returns 401 (no existence oracle)', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: 'no_such_user_xyz', password: 'whatever' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PAT caller can bind', async () => {
|
||||||
|
const pat = await makePat();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${pat}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no bearer token returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing username/password returns 400', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
|
||||||
|
test('an agent can search the user tree', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||||
|
expect(res.body.entries.length).toBeGreaterThan(0);
|
||||||
|
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PAT caller is denied search (agent-only)', async () => {
|
||||||
|
const pat = await makePat();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${pat}`)
|
||||||
|
.send({ filter: `(uid=${TEST_CREDS.uid})` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing filter returns 400', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Pure-logic coverage for the two reconciler rules that real Proxmox + UniFi
|
||||||
|
// data broke. Both were found by running discovery against a live cluster:
|
||||||
|
// the directory came back listing MAC addresses as host names, and one
|
||||||
|
// resource ended up as its own parent.
|
||||||
|
|
||||||
|
// Mirrors the ranking in services/discovery_reconciler.js. Kept here (rather
|
||||||
|
// than exported) because it is a few lines of predicate that the reconciler
|
||||||
|
// applies inline while merging; if it grows, export it and drop this copy.
|
||||||
|
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
|
||||||
|
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
|
||||||
|
const nameRank = (str) => {
|
||||||
|
if (!str || !String(str).trim()) return 0;
|
||||||
|
if (isMac(str)) return 0;
|
||||||
|
if (isIp(str)) return 1;
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
function bestNameOf(existingName, incomingName) {
|
||||||
|
let best = existingName;
|
||||||
|
if (incomingName) {
|
||||||
|
const a = nameRank(incomingName);
|
||||||
|
const b = nameRank(best);
|
||||||
|
if (a > b || (a === b && incomingName.length > (best || '').length)) best = incomingName;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('discovery name ranking', () => {
|
||||||
|
test('a real hostname beats a MAC even when shorter', () => {
|
||||||
|
// The exact regression: UniFi named the host by MAC, Proxmox knew the
|
||||||
|
// hostname, and length-only comparison kept the MAC.
|
||||||
|
expect(bestNameOf('ac:16:2d:b3:da:80', 'dl380-0')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a MAC never displaces a real hostname', () => {
|
||||||
|
expect(bestNameOf('dl380-0', 'ac:16:2d:b3:da:80')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real hostname beats an IP-shaped name', () => {
|
||||||
|
expect(bestNameOf('192.168.1.27', 'hass.io')).toBe('hass.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an IP beats a MAC', () => {
|
||||||
|
expect(bestNameOf('bc:24:11:3f:cd:c8', '192.168.1.27')).toBe('192.168.1.27');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an IP does not displace a hostname', () => {
|
||||||
|
expect(bestNameOf('gitea-runner', '192.168.1.176')).toBe('gitea-runner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('within the same rank the longer/more specific name wins', () => {
|
||||||
|
expect(bestNameOf('pve', 'pve-dl380-1')).toBe('pve-dl380-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dash-separated MACs are recognized too', () => {
|
||||||
|
expect(bestNameOf('ac-16-2d-b3-da-80', 'dl380-0')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty existing name is always replaced', () => {
|
||||||
|
expect(bestNameOf('', 'anything')).toBe('anything');
|
||||||
|
expect(bestNameOf(null, 'ac:16:2d:b3:da:80')).toBe('ac:16:2d:b3:da:80');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirrors isDescendant() in the reconciler.
|
||||||
|
function isDescendant(candidateId, rootId, edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (id === candidateId) return true;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('discovery edge cycle guard', () => {
|
||||||
|
const edges = [
|
||||||
|
{ parentId: 'cluster', childId: 'node1' },
|
||||||
|
{ parentId: 'node1', childId: 'vm1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('detects a direct parent/child inversion', () => {
|
||||||
|
// Proposing node1 -> cluster when cluster -> node1 already exists.
|
||||||
|
expect(isDescendant('node1', 'cluster', edges)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects a deeper loop', () => {
|
||||||
|
expect(isDescendant('vm1', 'cluster', edges)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows an unrelated new parent', () => {
|
||||||
|
expect(isDescendant('node2', 'cluster', edges)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminates on a graph that already contains a cycle', () => {
|
||||||
|
// A self-edge written by an earlier release must not hang the walk.
|
||||||
|
const cyclic = [{ parentId: 'a', childId: 'a' }, { parentId: 'a', childId: 'b' }];
|
||||||
|
expect(isDescendant('zzz', 'a', cyclic)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
GOD_ADMIN,
|
||||||
|
} = require('../utils/groups');
|
||||||
|
|
||||||
|
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
|
||||||
|
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
|
||||||
|
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
|
||||||
|
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
|
||||||
|
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
|
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
|
||||||
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||||
|
expect(slugify('Web 01')).toBe('web-01');
|
||||||
|
expect(slugify('Main Office')).toBe('main-office');
|
||||||
|
expect(slugify('my_host')).toBe('my-host');
|
||||||
|
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||||
|
expect(slugify('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group cn builders', () => {
|
||||||
|
test('per-resource names the kind + name slug (docs §2)', () => {
|
||||||
|
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
|
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||||
|
});
|
||||||
|
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
|
||||||
|
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||||
|
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
|
||||||
|
});
|
||||||
|
test('aggregate uses the plural kind', () => {
|
||||||
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
|
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||||
|
});
|
||||||
|
test('site super admin + everyone', () => {
|
||||||
|
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
|
});
|
||||||
|
test('a directory site slug with a kind prefix is kept verbatim', () => {
|
||||||
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
|
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||||
|
});
|
||||||
|
test('invalid kind throws', () => {
|
||||||
|
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||||
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('levels', () => {
|
||||||
|
test('admin/access known; capabilities opaque', () => {
|
||||||
|
expect(isKnownLevel('admin')).toBe(true);
|
||||||
|
expect(isKnownLevel('access')).toBe(true);
|
||||||
|
expect(isKnownLevel('reboot')).toBe(false);
|
||||||
|
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(levelGrants('admin', 'access')).toBe(true);
|
||||||
|
expect(levelGrants('access', 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasPermission — inheritance', () => {
|
||||||
|
test('god_admin grants everything everywhere', () => {
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site super admin grants everything on its site, not other sites', () => {
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specific host group grants only that host', () => {
|
||||||
|
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
|
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
|
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||||
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service maps to the app kind (docs §11)', () => {
|
||||||
|
// The directory `service` kind is the group model's `app`.
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-site isolation', () => {
|
||||||
|
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const nmapPlugin = require('../plugins/discovery/nmap');
|
||||||
|
|
||||||
|
jest.mock('node-nmap', () => {
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
class MockNmapScan extends EventEmitter {
|
||||||
|
constructor(targetRange, customFlags) {
|
||||||
|
super();
|
||||||
|
this.targetRange = targetRange;
|
||||||
|
this.customFlags = customFlags;
|
||||||
|
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
||||||
|
}
|
||||||
|
startScan() {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('complete', [
|
||||||
|
{ ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
NmapScan: MockNmapScan,
|
||||||
|
nmapLocation: 'nmap'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nmap discovery plugin', () => {
|
||||||
|
test('discover passes custom flags (-Pn, -sT, -F, --min-rate) to constructor', async () => {
|
||||||
|
const logs = [];
|
||||||
|
const result = await nmapPlugin.discover({
|
||||||
|
targetRange: '192.168.1.0/24',
|
||||||
|
log: (msg) => { logs.push(msg); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const startLog = logs.find(l => l.startsWith('Starting nmap scan'));
|
||||||
|
expect(startLog).toBeDefined();
|
||||||
|
expect(startLog).toContain('-Pn');
|
||||||
|
expect(startLog).toContain('-sT');
|
||||||
|
expect(startLog).toContain('-F');
|
||||||
|
expect(startLog).toContain('--min-rate 100');
|
||||||
|
expect(result.resources).toHaveLength(2); // host + service
|
||||||
|
expect(result.resources[0].name).toBe('host-10');
|
||||||
|
expect(result.edges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
|
||||||
|
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
|
||||||
|
// nothing catches it until the line actually runs, so it can sit in a rarely
|
||||||
|
// exercised path indefinitely.
|
||||||
|
//
|
||||||
|
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
|
||||||
|
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
|
||||||
|
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
|
||||||
|
// delivery had simply never worked.
|
||||||
|
const ORM_MODELS = [
|
||||||
|
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
|
||||||
|
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
|
||||||
|
'Agent', 'AgentJoinKey',
|
||||||
|
];
|
||||||
|
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
|
||||||
|
|
||||||
|
function walk(dir, out = []) {
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
walk(full, out);
|
||||||
|
} else if (entry.name.endsWith('.js')) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
|
||||||
|
// isn't reported as the bug.
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no source file calls an ORM static that does not exist', () => {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
const m = line.match(pattern);
|
||||||
|
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1} — ${m.join(', ')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
|
||||||
|
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
|
||||||
|
// threw "Email.send is not a function", so the Test Email button could never
|
||||||
|
// have worked.
|
||||||
|
test('the email module exports Mail.send and callers destructure it', () => {
|
||||||
|
const mod = require('../models/email');
|
||||||
|
expect(typeof mod.Mail).toBe('object');
|
||||||
|
expect(typeof mod.Mail.send).toBe('function');
|
||||||
|
// The bare module has no send() -- this is exactly the mistake to catch.
|
||||||
|
expect(mod.send).toBeUndefined();
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
// `X = require('...email')` followed by `X.send(` where X was not
|
||||||
|
// destructured.
|
||||||
|
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
|
||||||
|
.map(m => m[1]);
|
||||||
|
for (const name of assigned) {
|
||||||
|
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
|
||||||
|
offenders.push(`${path.relative(ROOT, file)} — ${name}.send(), but the module exports {Mail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
|
||||||
|
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
|
||||||
|
// (which test-sms used to POST to with Basic auth) does not exist -- it
|
||||||
|
// returned an HTML page, so response.json() threw
|
||||||
|
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
|
||||||
|
test('nothing targets the non-existent api.voip.ms host', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
// Comments stripped: the note in routes/api_conf.js explaining this
|
||||||
|
// very bug names the bad host, and describing a mistake is not
|
||||||
|
// making it.
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
if (line.includes('api.voip.ms')) {
|
||||||
|
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { _Interfaces: Interfaces } = require('../plugins/discovery/proxmox');
|
||||||
|
|
||||||
|
// Regression coverage for the MAC/IP mismatch: the plugin used to collect MACs
|
||||||
|
// and IPs into two flat lists and zip them by index, so on a multi-NIC guest
|
||||||
|
// -- or any guest where one NIC had no address -- the directory recorded an IP
|
||||||
|
// against the wrong MAC. Interfaces keys by MAC so a pairing can only come from
|
||||||
|
// the source that observed both together.
|
||||||
|
describe('proxmox Interfaces', () => {
|
||||||
|
test('keeps each IP on the NIC it was observed on', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('AA:BB:CC:00:00:01', ['10.0.0.5'], 'eth0');
|
||||||
|
i.add('AA:BB:CC:00:00:02', ['192.168.9.7'], 'eth1');
|
||||||
|
|
||||||
|
expect(i.toArray()).toEqual([
|
||||||
|
{ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5', ips: ['10.0.0.5'], name: 'eth0' },
|
||||||
|
{ mac: 'aa:bb:cc:00:00:02', ip: '192.168.9.7', ips: ['192.168.9.7'], name: 'eth1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a NIC with no address does not steal the next NIC\'s IP', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('AA:BB:CC:00:00:01', [], 'eth0'); // stopped/unconfigured
|
||||||
|
i.add('AA:BB:CC:00:00:02', ['10.0.0.9'], 'eth1');
|
||||||
|
|
||||||
|
const byMac = Object.fromEntries(i.toArray().map(x => [x.mac, x.ip]));
|
||||||
|
expect(byMac['aa:bb:cc:00:00:01']).toBeNull();
|
||||||
|
expect(byMac['aa:bb:cc:00:00:02']).toBe('10.0.0.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merges the config MAC with the agent-reported address for the same NIC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', ['10.0.0.5'], 'eth0'); // guest agent
|
||||||
|
i.add('AA:BB:CC:00:00:01', [], 'net0'); // VM config, same NIC
|
||||||
|
expect(i.toArray()).toHaveLength(1);
|
||||||
|
expect(i.toArray()[0]).toMatchObject({ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collects multiple addresses on one NIC without inventing a second NIC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', ['10.0.0.5', '10.0.0.6'], 'eth0');
|
||||||
|
expect(i.toArray()).toHaveLength(1);
|
||||||
|
expect(i.toArray()[0].ips).toEqual(['10.0.0.5', '10.0.0.6']);
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores placeholder and malformed MACs', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('00:00:00:00:00:00', [], 'eth0');
|
||||||
|
i.add('not-a-mac', [], 'eth1');
|
||||||
|
i.add('', [], 'eth2');
|
||||||
|
expect(i.toArray()).toEqual([]);
|
||||||
|
expect(i.primaryMac()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps an address that arrived without a usable MAC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add(null, ['10.0.0.5'], 'eth0');
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.5');
|
||||||
|
expect(i.primaryMac()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primary values prefer a NIC that actually has an address', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', [], 'eth0');
|
||||||
|
i.add('aa:bb:cc:00:00:02', ['10.0.0.9'], 'eth1');
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.9');
|
||||||
|
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fully unaddressed guest still reports its MAC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', [], 'net0');
|
||||||
|
expect(i.primaryIp()).toBeNull();
|
||||||
|
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
request: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('redis', () => ({
|
||||||
|
createClient: () => ({
|
||||||
|
on: jest.fn(),
|
||||||
|
connect: jest.fn().mockResolvedValue(),
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
set: jest.fn().mockResolvedValue(),
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
|
||||||
|
// can run without a database.
|
||||||
|
jest.mock('../models/shared_secret', () => ({
|
||||||
|
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/shared_secret_grant', () => ({
|
||||||
|
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/vault_app_token', () => {
|
||||||
|
const rows = [];
|
||||||
|
const VaultAppToken = {
|
||||||
|
_rows: rows,
|
||||||
|
list: jest.fn(async () => rows),
|
||||||
|
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
|
||||||
|
create: jest.fn(async (data) => {
|
||||||
|
const row = {
|
||||||
|
...data,
|
||||||
|
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
|
||||||
|
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { VaultAppToken };
|
||||||
|
});
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
|
describe('vault_broker admin policy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
baoConf.request.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOrCreateAdminToken ensures sso-admin policy with list capabilities on metadata', async () => {
|
||||||
|
baoConf.request.mockImplementation(async (method, path, body) => {
|
||||||
|
if (method === 'GET' && path === 'sys/policies/acl/sso-admin') {
|
||||||
|
return { status: 404, text: async () => '' };
|
||||||
|
}
|
||||||
|
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
|
||||||
|
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
|
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
|
return { status: 204, ok: true };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ auth: { client_token: 'test-admin-token', lease_duration: 3600 } })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 200, ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await vaultBroker.getOrCreateAdminToken('adminuser');
|
||||||
|
expect(token).toBe('test-admin-token');
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('PUT', 'sys/policies/acl/sso-admin', expect.objectContaining({
|
||||||
|
policy: expect.stringContaining('path "secret/metadata/"')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('app token lifecycle (accessor storage + renewal)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
baoConf.request.mockReset();
|
||||||
|
VaultAppToken._rows.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
|
||||||
|
baoConf.request.mockImplementation(async (method, path, body) => {
|
||||||
|
if (path.startsWith('sys/policies/acl/')) {
|
||||||
|
if (method === 'GET') return { status: 404, text: async () => '' };
|
||||||
|
return { status: 204, ok: true };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/create/sso-app') {
|
||||||
|
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/renew-accessor') {
|
||||||
|
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/revoke-accessor') {
|
||||||
|
return { ok: true, status: 204, text: async () => '' };
|
||||||
|
}
|
||||||
|
return { status: 200, ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
|
||||||
|
mockBao({ mintAccessor: 'acc-old' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
|
||||||
|
|
||||||
|
mockBao({ mintAccessor: 'acc-new' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
|
||||||
|
mockBao();
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
VaultAppToken._rows[0].lastRenewedAt = 0;
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
|
||||||
|
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens records the failure on the row without throwing', async () => {
|
||||||
|
mockBao({ renewOk: false });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
|
||||||
|
// This exists because the proxy once shipped with a hook shape the installed
|
||||||
|
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
|
||||||
|
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
|
||||||
|
// request 403'd. A unit test on options can't catch that — only a wire test can.
|
||||||
|
describe('vaultProxy wire behavior', () => {
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
let target; // fake OpenBao
|
||||||
|
let seen; // last request the fake OpenBao received
|
||||||
|
let app; // sso app fragment: scopeGuard stub + vaultProxy
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
target = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
seen = { method: req.method, url: req.url, headers: req.headers, body };
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
target.listen(0, '127.0.0.1', () => {
|
||||||
|
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
|
||||||
|
jest.resetModules();
|
||||||
|
const broker = require('../utils/vault_broker');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(() => target.close(done));
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(path, opts = {}) {
|
||||||
|
const port = server.address().port;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${path}`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
|
||||||
|
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
|
||||||
|
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(seen.headers['auth-token']).toBeUndefined();
|
||||||
|
expect(seen.headers['authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
|
||||||
|
const res = await call('/api/vault/secret/data/users/alice/foo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
|
||||||
|
body: JSON.stringify({ data: { hello: 'world' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.method).toBe('POST');
|
||||||
|
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Authenticate an agent from a Bearer token (the same token the agent presents
|
||||||
|
// on its WSS channel). Used by agent-facing REST endpoints (secrets, IAM) that
|
||||||
|
// are NOT admin-gated — the caller is the agent itself, not an admin session.
|
||||||
|
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
|
||||||
|
// Resolve a Bearer token to its (non-revoked) Agent, or null. Every failure
|
||||||
|
// collapses to null so a probing caller learns nothing about which part was
|
||||||
|
// wrong.
|
||||||
|
async function authenticateAgent(req) {
|
||||||
|
const auth = req.headers['authorization'] || '';
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||||
|
if (!m) return null;
|
||||||
|
const token = String(m[1]).trim();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const agent = await Agent.authenticate(token);
|
||||||
|
return agent || null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authenticateAgent };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// The Ed25519 key pair the SSO signs high-risk agent commands with, stored in
|
||||||
|
// OpenBao at `secret/agent/signing-key`.
|
||||||
|
//
|
||||||
|
// This used to be generated in the AgentManager constructor and kept only in
|
||||||
|
// memory, which made the whole signing scheme decorative: every SSO restart
|
||||||
|
// produced a new key, so the `public_key` pinned in an agent's agent.yml stopped
|
||||||
|
// matching and the agent either rejected everything or (because it skips
|
||||||
|
// verification when no key is configured) executed everything unverified. A
|
||||||
|
// trust anchor that changes on restart is not a trust anchor.
|
||||||
|
//
|
||||||
|
// Requires the sso-broker OpenBao policy to grant `secret/agent/*`
|
||||||
|
// (theta-suite setup.sh). Without it the load fails and signing is reported as
|
||||||
|
// unavailable -- we deliberately do NOT fall back to an ephemeral key, because
|
||||||
|
// signing with a key no agent has ever seen is worse than refusing: it looks
|
||||||
|
// like it worked.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
const PATH = 'agent/signing-key'; // baoConf adds the secret/data prefix
|
||||||
|
|
||||||
|
let cached = null; // { privateKeyPem, publicKeyPem, publicKeyBase64 }
|
||||||
|
let loadError = null;
|
||||||
|
|
||||||
|
// Agents pin the raw 32-byte Ed25519 public key, base64-encoded (see the Go
|
||||||
|
// client's verifySignature, which base64-decodes cfg.public_key and expects
|
||||||
|
// ed25519.PublicKeySize bytes). Node hands us SPKI PEM, so strip the 12-byte
|
||||||
|
// DER prefix to get the raw key the agent actually wants.
|
||||||
|
function rawPublicKeyBase64(publicKeyPem) {
|
||||||
|
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
||||||
|
return Buffer.from(der.subarray(der.length - 32)).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
||||||
|
});
|
||||||
|
return { privateKeyPem: privateKey, publicKeyPem: publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the stored key pair, generating and persisting one on first run.
|
||||||
|
// Idempotent and safe to call repeatedly; the result is cached in-process.
|
||||||
|
async function load() {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
stored = await baoConf.get(PATH);
|
||||||
|
} catch (err) {
|
||||||
|
loadError = `could not read ${PATH} from OpenBao: ${err.message}`;
|
||||||
|
console.error(`[agent_keys] ${loadError}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored && stored.privateKeyPem && stored.publicKeyPem) {
|
||||||
|
cached = {
|
||||||
|
privateKeyPem: stored.privateKeyPem,
|
||||||
|
publicKeyPem: stored.publicKeyPem,
|
||||||
|
publicKeyBase64: rawPublicKeyBase64(stored.publicKeyPem)
|
||||||
|
};
|
||||||
|
loadError = null;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First run: mint one and persist it before use, so a crash between
|
||||||
|
// generating and storing can't leave agents pinned to a key we forgot.
|
||||||
|
const fresh = generate();
|
||||||
|
try {
|
||||||
|
await baoConf.set(PATH, fresh);
|
||||||
|
} catch (err) {
|
||||||
|
loadError = `could not persist a signing key to ${PATH}: ${err.message}. `
|
||||||
|
+ 'Re-run ./setup.sh so the sso-broker policy grants secret/agent/*.';
|
||||||
|
console.error(`[agent_keys] ${loadError}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = {
|
||||||
|
...fresh,
|
||||||
|
publicKeyBase64: rawPublicKeyBase64(fresh.publicKeyPem)
|
||||||
|
};
|
||||||
|
loadError = null;
|
||||||
|
console.log('[agent_keys] generated and stored a new agent signing key');
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
return { available: !!cached, error: loadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam: drop the in-process cache.
|
||||||
|
function _reset() {
|
||||||
|
cached = null;
|
||||||
|
loadError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH };
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const agentKeys = require('./agent_keys');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
|
||||||
|
// Tracks the live WebSocket for each enrolled agent and brokers commands to it.
|
||||||
|
//
|
||||||
|
// The durable facts about an agent (identity, host binding, last seen, last
|
||||||
|
// discovery/telemetry) live in the Agent table; this class holds only what
|
||||||
|
// cannot be persisted -- the open socket. That split is what makes an installed
|
||||||
|
// -but-offline agent visible, and what stops a restart from erasing the fleet.
|
||||||
|
class AgentManager {
|
||||||
|
constructor() {
|
||||||
|
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
|
||||||
|
this.live = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5:
|
||||||
|
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
|
||||||
|
*/
|
||||||
|
canonicalize(payload) {
|
||||||
|
const sortObj = (val) => {
|
||||||
|
if (val === null || typeof val !== 'object') return val;
|
||||||
|
if (Array.isArray(val)) return val.map(sortObj);
|
||||||
|
const sorted = {};
|
||||||
|
const keys = Object.keys(val).filter(k => k !== 'signature').sort();
|
||||||
|
for (const k of keys) {
|
||||||
|
sorted[k] = sortObj(val[k]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
return JSON.stringify(sortObj(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign payload using the persisted Ed25519 private key. Throws when no key is
|
||||||
|
* available rather than minting a throwaway one -- an agent verifies against
|
||||||
|
* the key pinned in its agent.yml, so a signature from a key it has never
|
||||||
|
* seen is not a weaker signature, it is a broken command that looks fine from
|
||||||
|
* this side.
|
||||||
|
*/
|
||||||
|
async signPayload(payload) {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
if (!keys) {
|
||||||
|
const { error } = agentKeys.status();
|
||||||
|
throw new Error(`agent command signing is unavailable: ${error || 'no signing key'}`);
|
||||||
|
}
|
||||||
|
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
|
||||||
|
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicKeyBase64() {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
return keys ? keys.publicKeyBase64 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicKeyPem() {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
return keys ? keys.publicKeyPem : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind a freshly authenticated socket to an enrolled agent. `agent` is an
|
||||||
|
// Agent row that Agent.authenticate() has already vouched for -- this method
|
||||||
|
// never sees a raw token and must never be called with an unauthenticated one.
|
||||||
|
// Synchronous by design. The caller must attach its `message` listener in the
|
||||||
|
// same tick as the connection is accepted: `ws` drops events emitted before a
|
||||||
|
// listener exists, and the agent sends `discovery` immediately on open, so
|
||||||
|
// awaiting a database round-trip here silently lost every agent's first
|
||||||
|
// discovery frame. The connect timestamp is persisted in the background.
|
||||||
|
registerAgent(agent, ws, remoteAddress) {
|
||||||
|
const existing = this.live.get(agent.id);
|
||||||
|
if (existing && existing.ws && existing.ws !== ws) {
|
||||||
|
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.live.set(agent.id, {
|
||||||
|
ws,
|
||||||
|
ipAddress: remoteAddress,
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
lastResponse: null
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.update({
|
||||||
|
last_seen: Math.floor(Date.now() / 1000),
|
||||||
|
last_ip: remoteAddress || null
|
||||||
|
}).catch(err => console.error(`[AgentManager] could not record connect for ${agent.id}:`, err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterAgent(agentId, ws) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (state && state.ws === ws) this.live.delete(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop an agent's live socket now. Revocation that only takes effect on the
|
||||||
|
// next reconnect is not revocation -- a connected agent would keep receiving
|
||||||
|
// commands indefinitely.
|
||||||
|
disconnect(agentId, code = 4003, reason = 'Disconnected by server') {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (!state || !state.ws) return false;
|
||||||
|
try { state.ws.close(code, reason); } catch (e) {}
|
||||||
|
this.live.delete(agentId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(agentId) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
return !!(state && state.ws && state.ws.readyState === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touch(agent, extra = {}) {
|
||||||
|
await agent.update({
|
||||||
|
last_seen: Math.floor(Date.now() / 1000),
|
||||||
|
...extra
|
||||||
|
}).catch(err => console.error(`[AgentManager] could not persist agent ${agent.id}:`, err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiscovery(agent, payload) {
|
||||||
|
const discovery = {
|
||||||
|
hostname: payload.hostname || '',
|
||||||
|
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
|
||||||
|
os: payload.os || '',
|
||||||
|
kernel: payload.kernel || '',
|
||||||
|
cpu: payload.cpu || '',
|
||||||
|
ram_total_gb: payload.ram_total_gb || 0,
|
||||||
|
disk_total_gb: payload.disk_total_gb || 0,
|
||||||
|
location: payload.location || 'default',
|
||||||
|
// The agent's enabled capabilities (from its local agent.yml). The agent
|
||||||
|
// is the authoritative source for what it will actually do.
|
||||||
|
capabilities: payload.capabilities || {}
|
||||||
|
};
|
||||||
|
await this.touch(agent, { lastDiscovery: discovery });
|
||||||
|
await this.applyDiscoveryToDirectory(agent, discovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An agent runs ON the host it describes, which makes it the most
|
||||||
|
// authoritative source the directory has -- more so than a hypervisor API or
|
||||||
|
// a network scan. It previously updated nothing at all: the facts sat on an
|
||||||
|
// in-memory record and were lost on disconnect.
|
||||||
|
//
|
||||||
|
// When the agent is bound to a resource we write that row directly; guessing
|
||||||
|
// is only for an unbound agent, and then we let the shared reconciler do the
|
||||||
|
// matching (same MAC/IP/name rules every other source goes through) rather
|
||||||
|
// than inventing a second matcher here.
|
||||||
|
async applyDiscoveryToDirectory(agent, discovery) {
|
||||||
|
try {
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const metadata = {
|
||||||
|
os: discovery.os || undefined,
|
||||||
|
kernel: discovery.kernel || undefined,
|
||||||
|
cpu: discovery.cpu || undefined,
|
||||||
|
ram_total_gb: discovery.ram_total_gb || undefined,
|
||||||
|
disk_total_gb: discovery.disk_total_gb || undefined,
|
||||||
|
ip: (discovery.ip_addresses || [])[0] || undefined,
|
||||||
|
public_ip: discovery.public_ip || undefined,
|
||||||
|
agentId: agent.id,
|
||||||
|
last_seen: Date.now()
|
||||||
|
};
|
||||||
|
// Drop undefined so a field the agent could not determine never
|
||||||
|
// overwrites a good value already in the directory.
|
||||||
|
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
|
||||||
|
|
||||||
|
if (agent.resourceId) {
|
||||||
|
const resource = await Resource.get(agent.resourceId);
|
||||||
|
if (!resource) return;
|
||||||
|
const merged = { ...(resource.metadata || {}), ...metadata };
|
||||||
|
const sources = new Set(merged.discovery_sources || []);
|
||||||
|
sources.add('theta-agent');
|
||||||
|
merged.discovery_sources = [...sources];
|
||||||
|
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discovery.hostname) return;
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
const { ResourceEdge } = require('../models/resource');
|
||||||
|
|
||||||
|
const hostSlug = `host-${discovery.hostname.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`;
|
||||||
|
await DiscoveryReconciler.reconcile('theta-agent', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: discovery.hostname,
|
||||||
|
slug: hostSlug,
|
||||||
|
metadata: { ...metadata, subType: 'linux', managed: true }
|
||||||
|
}],
|
||||||
|
edges: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the matched or created host resource
|
||||||
|
const allHosts = await Resource.list({ where: { kind: 'host' } });
|
||||||
|
const hostRes = allHosts.find(r =>
|
||||||
|
r.name.toLowerCase() === discovery.hostname.toLowerCase() ||
|
||||||
|
r.slug === hostSlug ||
|
||||||
|
r.metadata?.agentId === agent.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hostRes) {
|
||||||
|
// Bind the agent to its Host resource
|
||||||
|
await agent.update({ resourceId: hostRes.id }).catch(() => {});
|
||||||
|
|
||||||
|
// Attach host to matching Site by Public IP if not already parented
|
||||||
|
const existingEdges = await ResourceEdge.list({ where: { childId: hostRes.id } });
|
||||||
|
if (existingEdges.length === 0) {
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||||
|
let targetSite = null;
|
||||||
|
if (discovery.public_ip) {
|
||||||
|
targetSite = sites.find(s => {
|
||||||
|
const siteIp = (s.metadata?.public_ip || s.metadata?.ip || s.metadata?.address || '').trim();
|
||||||
|
return siteIp && (siteIp === discovery.public_ip || siteIp.includes(discovery.public_ip));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!targetSite) targetSite = sites[0];
|
||||||
|
|
||||||
|
if (targetSite) {
|
||||||
|
await ResourceEdge.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
parentId: targetSite.id,
|
||||||
|
childId: hostRes.id,
|
||||||
|
relation: 'hosts'
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Never let a directory write break the agent connection.
|
||||||
|
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTelemetry(agent, payload) {
|
||||||
|
await this.touch(agent, {
|
||||||
|
lastTelemetry: {
|
||||||
|
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
||||||
|
ram_usage_percent: payload.ram_usage_percent || 0,
|
||||||
|
disk_usage_percent: payload.disk_usage_percent || 0,
|
||||||
|
zfs_health: payload.zfs_health || 'N/A',
|
||||||
|
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
||||||
|
timestamp: payload.timestamp || new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleHeartbeat(agent, payload, ws) {
|
||||||
|
await this.touch(agent);
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'heartbeat_ack',
|
||||||
|
payload: { timestamp: new Date().toISOString() }
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleResponse(agent, payload) {
|
||||||
|
const state = this.live.get(agent.id);
|
||||||
|
if (state) {
|
||||||
|
state.lastResponse = {
|
||||||
|
status: payload.status || 'ok',
|
||||||
|
message: payload.message || '',
|
||||||
|
output: payload.output || '',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.touch(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
|
||||||
|
const state = this.live.get(agent.id);
|
||||||
|
if (!state || !state.ws || state.ws.readyState !== 1) {
|
||||||
|
throw new Error(`Agent "${agent.name}" is not connected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPayload = { ...payload };
|
||||||
|
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
|
||||||
|
|
||||||
|
const message = { type: commandType, payload: finalPayload };
|
||||||
|
state.ws.send(JSON.stringify(message));
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live view for one agent, for merging into its row.
|
||||||
|
liveState(agentId) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (!state) return { connected: false, lastResponse: null };
|
||||||
|
return {
|
||||||
|
connected: !!(state.ws && state.ws.readyState === 1),
|
||||||
|
ipAddress: state.ipAddress,
|
||||||
|
connectedAt: state.connectedAt,
|
||||||
|
lastResponse: state.lastResponse || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every enrolled agent, connected or not.
|
||||||
|
async listAgents() {
|
||||||
|
const rows = await Agent.list();
|
||||||
|
return rows.map(a => a.toPublic(this.liveState(a.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new AgentManager();
|
||||||
|
module.exports.AgentManager = AgentManager;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Theta42 group & permission model.
|
||||||
|
//
|
||||||
|
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||||
|
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||||
|
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||||
|
// uses the singular (host/app).
|
||||||
|
//
|
||||||
|
// god_admin global — everything, everywhere
|
||||||
|
// {site}_super_admin everything on the site
|
||||||
|
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||||
|
// {site}_hosts_<level>
|
||||||
|
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||||
|
// {site}_apps_<level> ... on ALL apps at the site
|
||||||
|
// {site}_app_<slug>_<level> ... on ONE app
|
||||||
|
// {site}_everyone / everyone meta groups (implicit membership)
|
||||||
|
//
|
||||||
|
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||||
|
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||||
|
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||||
|
//
|
||||||
|
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||||
|
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||||
|
|
||||||
|
const GOD_ADMIN = 'god_admin';
|
||||||
|
const KNOWN_LEVELS = ['admin', 'access'];
|
||||||
|
const KINDS = ['host', 'app'];
|
||||||
|
|
||||||
|
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||||
|
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a kind (host/app) — throw on anything else.
|
||||||
|
function assertKind(kind) {
|
||||||
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
|
||||||
|
// `theta-env`), leaving the resource's name slug. Services are stored bare
|
||||||
|
// (`sso-manager`), so this is a no-op for them.
|
||||||
|
function resourceNameSlug(slug) {
|
||||||
|
return String(slug || '').replace(/^(site|host|app)_/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
|
||||||
|
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
|
||||||
|
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
|
||||||
|
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
|
||||||
|
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
|
||||||
|
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
|
||||||
|
// `site_local_app_sso-manager_access`.
|
||||||
|
function resourceGroupCns(site, kind, nameSlug, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
|
function aggregateGroupCns(site, kind, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}s_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_super_admin
|
||||||
|
function siteSuperAdminCns(site) {
|
||||||
|
return `${site}_super_admin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_everyone
|
||||||
|
function siteEveryoneCns(site) {
|
||||||
|
return `${site}_everyone`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `level` is a known admin/access level (not an opaque capability).
|
||||||
|
function isKnownLevel(level) {
|
||||||
|
return KNOWN_LEVELS.includes(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if holding `level` grants `wanted` (admin implies access).
|
||||||
|
function levelGrants(level, wanted) {
|
||||||
|
if (level === wanted) return true;
|
||||||
|
return level === 'admin' && wanted === 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||||
|
// `level` on a resource. Applies the inheritance lattice:
|
||||||
|
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||||
|
//
|
||||||
|
// memberOf: array of group cns the user is a member of.
|
||||||
|
// resource: { site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
//
|
||||||
|
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||||
|
// are resource-level grants, resolved by the caller against the resource's own
|
||||||
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
|
// the user's membership only.
|
||||||
|
function hasPermission(memberOf, resource, level) {
|
||||||
|
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
|
||||||
|
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
|
||||||
|
// apps); `nameSlug` is the resource name with any kind prefix stripped.
|
||||||
|
const site = resource && resource.site;
|
||||||
|
const rawKind = resource && resource.kind;
|
||||||
|
const kind = rawKind === 'service' ? 'app' : rawKind;
|
||||||
|
const nameSlug = resourceNameSlug(resource && resource.slug);
|
||||||
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
|
if (set.has(siteSuperAdminCns(site))) return true;
|
||||||
|
|
||||||
|
if (isKnownLevel(level)) {
|
||||||
|
// admin / access
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GOD_ADMIN,
|
||||||
|
KNOWN_LEVELS,
|
||||||
|
KINDS,
|
||||||
|
slugify,
|
||||||
|
resourceNameSlug,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// LDAP byte-pump relay (DESIGN.md §4). The agent forwards raw LDAP bytes from a
|
||||||
|
// local socket (SSSD) over the WSS channel as `ldap_tunnel` messages; this
|
||||||
|
// module relays them into the SSO's real OpenLDAP and pipes the responses back.
|
||||||
|
// The SSO does not parse LDAP either — it is a transparent socket relay.
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
|
||||||
|
// Parse host:port from an ldap:// or ldaps:// URL. The relay connects plaintext
|
||||||
|
// to the SSO's own slapd (which is plaintext on localhost); an ldaps:// URL
|
||||||
|
// would need TLS termination here and is not supported yet (DESIGN.md §9.5).
|
||||||
|
function ldapTarget() {
|
||||||
|
const url = conf.url || 'ldap://localhost:389';
|
||||||
|
const m = /^ldaps?:\/\/([^:/]+)(?::(\d+))?/.exec(url);
|
||||||
|
const host = m ? m[1] : 'localhost';
|
||||||
|
const port = m && m[2] ? Number(m[2]) : 389;
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-agent relay state: agentId -> Map(conn_id -> LDAP socket).
|
||||||
|
const relays = new Map();
|
||||||
|
|
||||||
|
function relayFor(agentId) {
|
||||||
|
if (!relays.has(agentId)) relays.set(agentId, new Map());
|
||||||
|
return relays.get(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle one ldap_tunnel message from an agent.
|
||||||
|
function handleTunnel(agentId, ws, payload) {
|
||||||
|
const connId = payload.conn_id;
|
||||||
|
if (!connId) return;
|
||||||
|
const conns = relayFor(agentId);
|
||||||
|
|
||||||
|
// End of connection: close the relay socket.
|
||||||
|
if (payload.close) {
|
||||||
|
const sock = conns.get(connId);
|
||||||
|
if (sock) { sock.destroy(); conns.delete(connId); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(payload.data || '', 'base64');
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
let sock = conns.get(connId);
|
||||||
|
if (!sock) {
|
||||||
|
const { host, port } = ldapTarget();
|
||||||
|
sock = net.connect(port, host);
|
||||||
|
conns.set(connId, sock);
|
||||||
|
|
||||||
|
// Relay OpenLDAP's responses back to the agent.
|
||||||
|
sock.on('data', (chunk) => {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'ldap_tunnel',
|
||||||
|
payload: { conn_id: connId, data: chunk.toString('base64') }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.on('close', () => {
|
||||||
|
conns.delete(connId);
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'ldap_tunnel',
|
||||||
|
payload: { conn_id: connId, close: true }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.on('error', () => { sock.destroy(); });
|
||||||
|
}
|
||||||
|
sock.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop every relay socket for an agent (on WSS disconnect).
|
||||||
|
function cleanup(agentId) {
|
||||||
|
const conns = relays.get(agentId);
|
||||||
|
if (conns) {
|
||||||
|
for (const sock of conns.values()) sock.destroy();
|
||||||
|
relays.delete(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleTunnel, cleanup };
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
const groups = require('./groups');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
// The group nested into every resource's _admin group by api_directory_admin
|
||||||
|
// (cross-resource super-admin administration). This is `god_admin` -- the global
|
||||||
|
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
|
||||||
|
// It used to be the legacy `app_super_admin`, which existed while god_admin
|
||||||
|
// didn't; now that god_admin is created at boot, the provisioning nests it.
|
||||||
|
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
|
||||||
|
// the migration, so an existing deployment isn't stripped of rights until it's
|
||||||
|
// rebuilt.
|
||||||
|
const SUPER_ADMIN_GROUP = 'god_admin';
|
||||||
|
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
// True if the user (by resolved member cns) is a global god/super admin.
|
||||||
|
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
|
||||||
|
async function isSuperAdmin(memberOfCns) {
|
||||||
|
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||||
|
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let byGroup = async function(user, checkGroups, ownerOf){
|
||||||
// Membership is resolved once, transitively: a user placed in an admin group
|
// Membership is resolved once, transitively: a user placed in an admin group
|
||||||
// through a nested group is as much a member as one listed on it directly.
|
// through a nested group is as much a member as one listed on it directly.
|
||||||
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||||
@@ -17,9 +34,9 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
// they still catch direct membership if the resolver is unavailable.
|
// they still catch direct membership if the resolver is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
if(await isSuperAdmin(memberOfCns)) return true;
|
||||||
|
|
||||||
for(let group of groups){
|
for(let group of checkGroups){
|
||||||
if(memberOfCns.includes(group)) return true;
|
if(memberOfCns.includes(group)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,4 +59,46 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
// Resolve whether a user has `level` on a directory resource under the group
|
||||||
|
// model (see utils/groups.js). Applies the inheritance lattice and the
|
||||||
|
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
|
||||||
|
//
|
||||||
|
// user: the auth user ({ dn, isMachine }).
|
||||||
|
// resource:{ site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
// grantedGroups: optional array of the resource's granted group cns (used only
|
||||||
|
// for meta `everyone` handling). Omit to skip meta grants.
|
||||||
|
async function onResource(user, resource, level, grantedGroups) {
|
||||||
|
let memberOfCns = [];
|
||||||
|
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (await isSuperAdmin(memberOfCns)) return true;
|
||||||
|
if (groups.hasPermission(memberOfCns, resource, level)) return true;
|
||||||
|
|
||||||
|
// Meta grants: `everyone` / `{site}_everyone` confer access to any
|
||||||
|
// authenticated (non-machine) user when the resource grants them.
|
||||||
|
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
|
||||||
|
const siteEveryone = groups.siteEveryoneCns(resource.site);
|
||||||
|
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like onResource but throws Insufficient Permission when denied — for guards.
|
||||||
|
async function requireResource(user, resource, level, grantedGroups) {
|
||||||
|
if (await onResource(user, resource, level, grantedGroups)) return;
|
||||||
|
const error = new Error('Insufficient Permission');
|
||||||
|
error.name = 'Insufficient Permission';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
byGroup,
|
||||||
|
onResource,
|
||||||
|
requireResource,
|
||||||
|
isSuperAdmin,
|
||||||
|
SUPER_ADMIN_GROUP,
|
||||||
|
LEGACY_SUPER_ADMIN_ALIASES,
|
||||||
|
...groups, // group schema builders (slugify, resourceGroupCns, ...)
|
||||||
|
};
|
||||||
|
|||||||
@@ -38,16 +38,13 @@ module.exports = {
|
|||||||
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
nav: [
|
nav: [
|
||||||
// Ungated on purpose: the catalog is the one page that exists for
|
// Catalog requires login - it's the end-user view of their accessible resources.
|
||||||
// ordinary users. Before this, every nav item was admin-only and a
|
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
|
||||||
// non-admin had no signposted destination at all.
|
|
||||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
|
||||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
|
||||||
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
// Vault requires login - per-user secrets at secret/users/<uid>/*.
|
||||||
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
|
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
|
||||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,15 +4,25 @@
|
|||||||
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||||
// `sso-broker` token role created by theta-env/setup.sh.
|
// `sso-broker` token role created by theta-env/setup.sh.
|
||||||
//
|
//
|
||||||
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||||
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
|
||||||
// secret/* admin UI sessions (sso-admin policy)
|
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||||
|
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
|
||||||
|
// secret/* admin UI sessions (sso-admin policy)
|
||||||
//
|
//
|
||||||
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
||||||
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
||||||
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
||||||
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
||||||
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
||||||
|
//
|
||||||
|
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
|
||||||
|
// live at token use (only the SET of policy names on a token is fixed at mint),
|
||||||
|
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
|
||||||
|
// — cached or freshly minted. That way a stale cached token immediately gains
|
||||||
|
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
|
||||||
|
// an existing grantee token with no re-mint. The Redis cache only short-circuits
|
||||||
|
// token MINTING, never policy reconciliation.
|
||||||
|
|
||||||
const baoConf = require('@simpleworkjs/bao-conf');
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
const { createClient } = require('redis');
|
const { createClient } = require('redis');
|
||||||
@@ -20,6 +30,9 @@ const express = require('express');
|
|||||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const permission = require('./permission');
|
const permission = require('./permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
const ROLE = 'sso-broker';
|
const ROLE = 'sso-broker';
|
||||||
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||||
@@ -54,59 +67,104 @@ async function bao(method, path, body) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
|
||||||
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
// content and only PUT when it differs. `bao policy write` is an idempotent
|
||||||
// a list grant on a directory path) propagate on the next vault-page visit
|
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
||||||
// without an operator re-running setup.sh. Skipping on an existing policy
|
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||||
// 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 && existing.status !== 404) {
|
if (existing.status !== 200 && 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}`);
|
||||||
}
|
}
|
||||||
|
if (existing.status === 200) {
|
||||||
|
const body = await existing.json().catch(() => null);
|
||||||
|
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
||||||
|
}
|
||||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint a token through the sso-broker role with the given policies. Returns
|
// Mint a token through a token role with the given policies. Returns
|
||||||
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
|
||||||
async function mintToken(policies) {
|
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
|
||||||
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
// from cache) and sso-app (768h period — long-lived external-app credentials,
|
||||||
|
// kept alive via their stored accessor by the renewal loop below).
|
||||||
|
async function mintToken(policies, role = ROLE) {
|
||||||
|
const res = await bao('POST', `auth/token/create/${role}`, { policies });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const token = json && json.auth && json.auth.client_token;
|
const token = json && json.auth && json.auth.client_token;
|
||||||
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||||
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||||
return { token, ttl };
|
return { token, accessor: json.auth.accessor, ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared-secret policy rules ───────────────────────────────────────────────
|
||||||
|
// Returns the HCL rules granting `read` on every shared secret the given
|
||||||
|
// grantee (a user uid or an app name) has been granted. Enforcement is
|
||||||
|
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
|
||||||
|
// effect for the grantee's existing token immediately (no re-mint).
|
||||||
|
async function sharedPolicyRules(granteeType, granteeId) {
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
|
||||||
|
if (!grants.length) return '';
|
||||||
|
const secretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const secrets = secretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
|
||||||
|
const byId = new Map(secrets.map(s => [s.id, s]));
|
||||||
|
const rules = [];
|
||||||
|
for (const g of grants) {
|
||||||
|
const sec = byId.get(g.secretId);
|
||||||
|
if (!sec) continue;
|
||||||
|
const p = sec.path(); // shared/<ownerUid>/<slug>
|
||||||
|
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
|
||||||
|
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
|
||||||
|
}
|
||||||
|
return rules.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-user token ──────────────────────────────────────────────────────────
|
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||||
function userPolicyHcl(uid) {
|
async function userPolicyHcl(uid) {
|
||||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
const granted = await sharedPolicyRules('user', uid);
|
||||||
// into a policy path, so reject anything but a safe charset.
|
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// directory itself, so without it the /vault secrets list 403s.
|
path "secret/metadata/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 = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
|
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
|
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
|
||||||
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
// (compare-and-skip) before the cache is consulted, so a cached token can never
|
||||||
|
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
|
||||||
|
// when the cache entry expires (a little before the token's own TTL).
|
||||||
async function getOrCreateUserToken(uid) {
|
async function getOrCreateUserToken(uid) {
|
||||||
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
||||||
|
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
|
||||||
const cacheKey = `vault_token:${uid}`;
|
const cacheKey = `vault_token:${uid}`;
|
||||||
const cached = await cacheGet(cacheKey);
|
const cached = await cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
|
||||||
const { token, ttl } = await mintToken([`user-${uid}`]);
|
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||||
|
function adminPolicyHcl() {
|
||||||
|
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getOrCreateAdminToken(uid) {
|
async function getOrCreateAdminToken(uid) {
|
||||||
|
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||||
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||||
const cached = await cacheGet(cacheKey);
|
const cached = await cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -116,27 +174,144 @@ 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) {
|
async function appPolicyHcl(name) {
|
||||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
const granted = await sharedPolicyRules('app', name);
|
||||||
// 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/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||||
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
||||||
// a later compromise of an admin session cannot recover previously-minted app
|
// a later compromise of an admin session cannot recover previously-minted app
|
||||||
// tokens. The caller must record it in the external app immediately.
|
// tokens. The caller must record it in the external app immediately. Later
|
||||||
async function mintAppToken(name) {
|
// grants to the app edit app-<name> policy content (live-applied to this token).
|
||||||
|
//
|
||||||
|
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
|
||||||
|
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
|
||||||
|
// token alive and lets a re-mint revoke the app's previous token so exactly
|
||||||
|
// one credential per app is ever live.
|
||||||
|
async function mintAppToken(name, actorUid) {
|
||||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||||
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||||
}
|
}
|
||||||
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
|
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
|
||||||
const { token, ttl } = await mintToken([`app-${name}`]);
|
// App tokens are long-lived credentials: mint via the sso-app role (768h
|
||||||
|
// period) so a renewal inside every 32-day window keeps them alive forever.
|
||||||
|
// Fall back to the broker's own 24h role on deployments whose setup.sh
|
||||||
|
// predates the sso-app role (re-running setup.sh creates it).
|
||||||
|
let minted;
|
||||||
|
try {
|
||||||
|
minted = await mintToken([`app-${name}`], 'sso-app');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
|
||||||
|
minted = await mintToken([`app-${name}`]);
|
||||||
|
}
|
||||||
|
const { token, accessor, ttl } = minted;
|
||||||
|
// Replace the app's accessor row; revoke the superseded token (best-effort —
|
||||||
|
// it may already be expired) so re-minting never leaves a zombie credential.
|
||||||
|
try {
|
||||||
|
const existing = await VaultAppToken.getByName(name);
|
||||||
|
if (existing) {
|
||||||
|
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
|
||||||
|
await existing.delete();
|
||||||
|
}
|
||||||
|
if (accessor) {
|
||||||
|
await VaultAppToken.create({
|
||||||
|
name, accessor,
|
||||||
|
lastRenewedAt: Date.now(),
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Accessor bookkeeping must never block handing the token out; without a
|
||||||
|
// row the token simply isn't auto-renewed (it still lives one full period).
|
||||||
|
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
|
||||||
|
}
|
||||||
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── App-token renewal loop ──────────────────────────────────────────────────
|
||||||
|
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
|
||||||
|
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
|
||||||
|
// far inside both possible periods (24h fallback and 768h), so a downstream
|
||||||
|
// app's token stays valid for as long as sso is running. Failures are recorded
|
||||||
|
// on the row (visible to admins in the DB / future UI) and never throw.
|
||||||
|
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
|
||||||
|
let renewTimer;
|
||||||
|
|
||||||
|
async function renewAppTokens() {
|
||||||
|
let rows;
|
||||||
|
try { rows = await VaultAppToken.list(); }
|
||||||
|
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
|
||||||
|
if (res.ok) {
|
||||||
|
await row.update({ lastRenewedAt: Date.now(), lastError: null });
|
||||||
|
} else {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
// 400 "invalid accessor" = token expired or was revoked out-of-band;
|
||||||
|
// keep the row + error so the admin can see the app needs a re-mint.
|
||||||
|
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
|
||||||
|
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
|
||||||
|
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the loop (idempotent). unref() so an open handle never blocks exit.
|
||||||
|
function startAppTokenRenewal() {
|
||||||
|
if (renewTimer) return renewTimer;
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
|
||||||
|
renewTimer = setInterval(() => {
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
|
||||||
|
}, RENEW_INTERVAL_MS);
|
||||||
|
if (renewTimer.unref) renewTimer.unref();
|
||||||
|
return renewTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grant / revoke shared-secret access ─────────────────────────────────────
|
||||||
|
// Creating a grant writes the DB row and then edits the grantee's policy content
|
||||||
|
// to add read on the shared path; revoking removes both. Because OpenBao parses
|
||||||
|
// policy content live, the change applies to the grantee's existing token
|
||||||
|
// immediately — no token re-mint, no cache invalidation needed.
|
||||||
|
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.create({
|
||||||
|
secretId, granteeType, granteeId, capability: 'read',
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
updated_by: actorUid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSharedSecret(grantId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.get(grantId);
|
||||||
|
if (!grant) return null;
|
||||||
|
const { granteeType, granteeId } = grant;
|
||||||
|
await grant.delete();
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute and rewrite a grantee's policy content after a grant/revoke.
|
||||||
|
async function reconcileGrantee(granteeType, granteeId) {
|
||||||
|
if (granteeType === 'user') {
|
||||||
|
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
|
||||||
|
} else if (granteeType === 'app') {
|
||||||
|
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
|
||||||
|
} else {
|
||||||
|
throw new Error(`invalid granteeType: ${granteeType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||||
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||||
// nothing). The guard mints a server-side token for the user (per-user or
|
// nothing). The guard mints a server-side token for the user (per-user or
|
||||||
@@ -145,11 +320,12 @@ async function mintAppToken(name) {
|
|||||||
// client's sso auth headers so OpenBao never sees them.
|
// client's sso auth headers so OpenBao never sees them.
|
||||||
|
|
||||||
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
const ADMIN_GROUP = 'app_sso_admin';
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
async function isAdmin(user) {
|
async function isAdmin(user) {
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(user, [ADMIN_GROUP]);
|
await permission.byGroup(user, ADMIN_GROUPS);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -178,17 +354,13 @@ async function scopeGuard(req, res, next) {
|
|||||||
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defense-in-depth: confirm the requested path is within the subject's
|
|
||||||
// namespace. Admins roam all of secret/; users are confined to
|
|
||||||
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
|
||||||
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
|
||||||
// 403 instead of an opaque OpenBao denial.)
|
|
||||||
const norm = normalizeVaultPath(req.path);
|
const norm = normalizeVaultPath(req.path);
|
||||||
if (norm === null) {
|
if (norm === null) {
|
||||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||||
}
|
}
|
||||||
const base = `/secret/users/${uid}`;
|
const userBase = `/secret/users/${uid}`;
|
||||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
const sharedBase = `/secret/shared`;
|
||||||
|
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return res.status(403).json({ error: 'path outside your vault namespace' });
|
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||||
}
|
}
|
||||||
@@ -202,16 +374,21 @@ function vaultProxy() {
|
|||||||
return createProxyMiddleware({
|
return createProxyMiddleware({
|
||||||
target: VAULT_ADDR,
|
target: VAULT_ADDR,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { '^/': '/v1/' },
|
pathRewrite: { '^/api/vault': '/v1' },
|
||||||
on: {
|
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
|
||||||
proxyReq(proxyReq, req, res, options) {
|
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
|
||||||
fixRequestBody(proxyReq, req, res, options);
|
// which shipped this proxy with NO token injection — every /api/vault
|
||||||
// Inject ONLY the server-minted scoped token; strip the client's
|
// call reached OpenBao unauthenticated and 403'd.
|
||||||
// sso session/api auth so it never reaches OpenBao.
|
onProxyReq(proxyReq, req, res, options) {
|
||||||
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
// Header ops MUST precede fixRequestBody: it write()s the parsed body
|
||||||
proxyReq.removeHeader('auth-token');
|
// onto proxyReq, which flushes headers — setHeader after that throws
|
||||||
proxyReq.removeHeader('authorization');
|
// (swallowed upstream), silently dropping the token on every write.
|
||||||
},
|
// Inject ONLY the server-minted scoped token; strip the client's
|
||||||
|
// sso session/api auth so it never reaches OpenBao.
|
||||||
|
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||||
|
proxyReq.removeHeader('auth-token');
|
||||||
|
proxyReq.removeHeader('authorization');
|
||||||
|
fixRequestBody(proxyReq, req, res, options);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -225,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
|
|||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
const name = (req.body && req.body.name || '').trim();
|
const name = (req.body && req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
const result = await mintAppToken(name);
|
const result = await mintAppToken(name, req.user && req.user.uid);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
@@ -233,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List the minted external-app tokens (metadata only — the token itself is shown
|
||||||
|
// once at mint and never stored; the accessor is a renewal/revoke handle and is
|
||||||
|
// never exposed). Lets the Apps tab show what has been minted instead of a
|
||||||
|
// credential vanishing into the void.
|
||||||
|
mintAppRouter.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const rows = await VaultAppToken.list();
|
||||||
|
res.json({ apps: rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
createdBy: r.created_by,
|
||||||
|
createdOn: r.created_on,
|
||||||
|
lastRenewedAt: r.lastRenewedAt || null,
|
||||||
|
lastError: r.lastError || null,
|
||||||
|
})) });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOrCreateUserToken,
|
getOrCreateUserToken,
|
||||||
getOrCreateAdminToken,
|
getOrCreateAdminToken,
|
||||||
@@ -241,4 +439,16 @@ module.exports = {
|
|||||||
scopeGuard,
|
scopeGuard,
|
||||||
vaultProxy,
|
vaultProxy,
|
||||||
mintAppRouter,
|
mintAppRouter,
|
||||||
};
|
// app-token lifecycle
|
||||||
|
renewAppTokens,
|
||||||
|
startAppTokenRenewal,
|
||||||
|
VaultAppToken,
|
||||||
|
// sharing
|
||||||
|
SharedSecret,
|
||||||
|
SharedSecretGrant,
|
||||||
|
userPolicyHcl,
|
||||||
|
appPolicyHcl,
|
||||||
|
grantSharedSecret,
|
||||||
|
revokeSharedSecret,
|
||||||
|
reconcileGrantee,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||||
|
|
||||||
|
var messagingTypes = {};
|
||||||
|
var messagingPlugins = [];
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadConf();
|
loadConf();
|
||||||
loadProxyConf();
|
loadProxyConf();
|
||||||
loadTos();
|
loadTos();
|
||||||
|
loadMessagingPlugins();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConf() {
|
async function loadConf() {
|
||||||
@@ -44,8 +49,8 @@
|
|||||||
|
|
||||||
async function saveConf() {
|
async function saveConf() {
|
||||||
const btn = $('#btn-save');
|
const btn = $('#btn-save');
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
smtp: {
|
smtp: {
|
||||||
host: $('#smtp-host').val(),
|
host: $('#smtp-host').val(),
|
||||||
@@ -72,14 +77,77 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.api.post('conf', payload);
|
await app.api.post('conf', payload);
|
||||||
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
app.messages.toast('Configuration saved successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendTestEmail() {
|
||||||
|
const to = $('#test-email-to').val().trim();
|
||||||
|
if (!to) {
|
||||||
|
app.messages.toast('Please enter a recipient email address', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = $('#btn-test-email');
|
||||||
|
const originalHtml = btn.html();
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
smtp: {
|
||||||
|
host: $('#smtp-host').val(),
|
||||||
|
port: parseInt($('#smtp-port').val(), 10) || 587,
|
||||||
|
user: $('#smtp-user').val(),
|
||||||
|
pass: $('#smtp-pass').val(),
|
||||||
|
from: $('#smtp-from').val(),
|
||||||
|
secure: $('#smtp-secure').is(':checked')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
const result = await app.api.post('conf/test-email', { to });
|
||||||
|
app.messages.toast(result.message || 'Test email sent!', 'success');
|
||||||
|
$('#test-email-to').val('');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html(originalHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestSms() {
|
||||||
|
const to = $('#test-sms-to').val().trim();
|
||||||
|
if (!to) {
|
||||||
|
app.messages.toast('Please enter a recipient phone number', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = $('#btn-test-sms');
|
||||||
|
const originalHtml = btn.html();
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
voipms: {
|
||||||
|
username: $('#voipms-username').val(),
|
||||||
|
did: $('#voipms-did').val(),
|
||||||
|
password: $('#voipms-password').val()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
const result = await app.api.post('conf/test-sms', { to });
|
||||||
|
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
||||||
|
$('#test-sms-to').val('');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html(originalHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePassword(id) {
|
function togglePassword(id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el.type === 'password') {
|
if (el.type === 'password') {
|
||||||
@@ -107,7 +175,7 @@
|
|||||||
|
|
||||||
async function saveProxyConf() {
|
async function saveProxyConf() {
|
||||||
const btn = $('#btn-save-proxy');
|
const btn = $('#btn-save-proxy');
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
oidc: {
|
oidc: {
|
||||||
@@ -126,22 +194,18 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets');
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></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() {
|
async function loadTos() {
|
||||||
try {
|
try {
|
||||||
const tos = await app.tos.get();
|
const tos = await app.tos.get();
|
||||||
document.getElementById('tos-content').value = tos.content;
|
if (tos && tos.content) {
|
||||||
document.getElementById('tos-meta').textContent =
|
document.getElementById('tos-content').value = tos.content;
|
||||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
document.getElementById('tos-meta').textContent =
|
||||||
|
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load ToS:', e);
|
console.error('Failed to load ToS:', e);
|
||||||
}
|
}
|
||||||
@@ -173,203 +237,287 @@
|
|||||||
loadTos();
|
loadTos();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Messaging Plugins ──────────────────────────────────────────────
|
||||||
|
function loadMessagingPlugins() {
|
||||||
|
app.api.get('plugins/types', function(err, res) {
|
||||||
|
if (!err && res && res.results) {
|
||||||
|
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
|
||||||
|
}
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if (err) return;
|
||||||
|
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
|
||||||
|
renderMessagingPlugins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessagingPlugins() {
|
||||||
|
const $list = $('#messaging-plugins-list').empty();
|
||||||
|
if (messagingPlugins.length === 0) {
|
||||||
|
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messagingPlugins.forEach(p => {
|
||||||
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
|
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||||
|
const card = `
|
||||||
|
<div class="card mb-3 border shadow-sm">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||||
|
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id, state) {
|
||||||
|
const endpoint = state ? 'load' : 'unload';
|
||||||
|
try {
|
||||||
|
await app.api.post(`plugins/${id}/${endpoint}`, {});
|
||||||
|
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id) {
|
||||||
|
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete(`plugins/${id}`);
|
||||||
|
app.messages.toast('Plugin deleted', 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container mt-4">
|
||||||
<div class="row mb-4">
|
<div class="row">
|
||||||
<div class="col d-flex justify-content-between align-items-center">
|
<div class="col-12">
|
||||||
<div>
|
<div class="card shadow">
|
||||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
|
||||||
<p class="text-muted mb-0">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
|
||||||
settings. These are stored securely in OpenBao and take effect immediately.
|
<li class="nav-item" role="presentation">
|
||||||
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
|
||||||
are masked — leave them unchanged to keep the stored value.
|
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
|
||||||
</p>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
<div>
|
<li class="nav-item" role="presentation">
|
||||||
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
|
||||||
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
|
||||||
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
|
||||||
<li class="nav-item" role="presentation">
|
</button>
|
||||||
<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>
|
<li class="nav-item" role="presentation">
|
||||||
<li class="nav-item" role="presentation">
|
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
|
||||||
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
|
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||||
</li>
|
</button>
|
||||||
<li class="nav-item" role="presentation">
|
</li>
|
||||||
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
|
<li class="nav-item" role="presentation">
|
||||||
</li>
|
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||||
<li class="nav-item" role="presentation">
|
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
</ul>
|
||||||
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
|
<div>
|
||||||
</li>
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
|
||||||
</ul>
|
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
|
||||||
|
</div>
|
||||||
<div class="tab-content" id="confTabsContent">
|
|
||||||
<!-- SMTP Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
|
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
<div class="card-body p-4">
|
||||||
<label class="form-label">Host</label>
|
<div class="tab-content" id="confTabContent">
|
||||||
<input type="text" class="form-control" id="smtp-host">
|
|
||||||
</div>
|
<!-- OAuth & JWT Tab -->
|
||||||
<div class="mb-3">
|
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
|
||||||
<label class="form-label">Port</label>
|
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
|
||||||
<input type="number" class="form-control" id="smtp-port">
|
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
<label class="form-label">User</label>
|
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
|
||||||
<input type="text" class="form-control" id="smtp-user">
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label fw-semibold">JWT Secret</label>
|
||||||
<label class="form-label">Password</label>
|
<div class="input-group">
|
||||||
<div class="input-group">
|
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||||
<input type="password" class="form-control" id="smtp-pass" 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('smtp-pass')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
|
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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 class="mb-3">
|
|
||||||
<label class="form-label">From Address</label>
|
|
||||||
<input type="text" class="form-control" id="smtp-from">
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
|
||||||
<label class="form-check-label">Use Secure (TLS)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OAuth Tab -->
|
<!-- SMTP Tab -->
|
||||||
<div class="tab-pane fade" id="oauth" role="tabpanel">
|
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
|
||||||
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
<div class="row">
|
||||||
</div>
|
<div class="col-md-8 mb-3">
|
||||||
<div class="card-body">
|
<label class="form-label fw-semibold">SMTP Host</label>
|
||||||
<div class="mb-3">
|
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
|
||||||
<label class="form-label">Issuer URL</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="oauth-issuer">
|
<div class="col-md-4 mb-3">
|
||||||
</div>
|
<label class="form-label fw-semibold">Port</label>
|
||||||
<div class="mb-3">
|
<input type="number" class="form-control" id="smtp-port" placeholder="587">
|
||||||
<label class="form-label">JWT Secret</label>
|
</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
<div class="row">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">User</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-user">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">From Address</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||||
|
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
|
||||||
|
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Saves current SMTP config and sends a test message.</div>
|
||||||
|
</div>
|
||||||
</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 class="mb-3">
|
|
||||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
|
||||||
<input type="number" class="form-control" id="oauth-token-access">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Refresh Token Lifetime (seconds)</label>
|
|
||||||
<input type="number" class="form-control" id="oauth-token-refresh">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SMS Tab -->
|
<!-- SMS & Messaging Tab -->
|
||||||
<div class="tab-pane fade" id="sms" role="tabpanel">
|
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
|
||||||
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
<div class="row">
|
||||||
</div>
|
<div class="col-md-6 mb-3">
|
||||||
<div class="card-body">
|
<label class="form-label fw-semibold">API Username</label>
|
||||||
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
<input type="text" class="form-control" id="voipms-username">
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label class="form-label">API Username</label>
|
<div class="col-md-6 mb-3">
|
||||||
<input type="text" class="form-control" id="voipms-username">
|
<label class="form-label fw-semibold">DID Sender Number</label>
|
||||||
</div>
|
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label class="form-label">DID (sender number)</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
<div class="mb-3">
|
||||||
</div>
|
<label class="form-label fw-semibold">API Password</label>
|
||||||
<div class="mb-3">
|
<div class="input-group">
|
||||||
<label class="form-label">API Password</label>
|
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||||
<div class="input-group">
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||||
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border mb-4">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
||||||
|
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="messaging-plugins-list"></div>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proxy Secrets Tab -->
|
<!-- Proxy Secrets Tab -->
|
||||||
<div class="tab-pane fade" id="proxy" role="tabpanel">
|
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
|
||||||
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
|
|
||||||
</div>
|
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
|
||||||
<div class="card-body">
|
<div class="mb-3">
|
||||||
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
|
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||||
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="row">
|
||||||
<label class="form-label">Issuer URL</label>
|
<div class="col-md-6 mb-3">
|
||||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
<label class="form-label fw-semibold">Client ID</label>
|
||||||
</div>
|
<input type="text" class="form-control" id="proxy-client-id">
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label class="form-label">Client ID</label>
|
<div class="col-md-6 mb-3">
|
||||||
<input type="text" class="form-control" id="proxy-client-id">
|
<label class="form-label fw-semibold">Client Secret</label>
|
||||||
</div>
|
<div class="input-group">
|
||||||
<div class="mb-3">
|
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
||||||
<label class="form-label">Client Secret</label>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Proxy 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>
|
||||||
|
|
||||||
|
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h6 class="mt-4 mb-2">LDAP Integration</h6>
|
<!-- Terms of Service Tab -->
|
||||||
<div class="mb-3">
|
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||||
<label class="form-label">Bind Password</label>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div class="input-group">
|
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
|
||||||
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
|
<span class="small text-muted" id="tos-meta"></span>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
|
||||||
|
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||||
|
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
|
||||||
|
<div id="tos-result" style="display:none" class="mt-3"></div>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var userlist;
|
|
||||||
var allGroups = [];
|
|
||||||
|
|
||||||
// A member DN under the groups base is a nested group, not a person. Both
|
|
||||||
// live in the same `member` attribute, so they have to be told apart here --
|
|
||||||
// otherwise a nested group renders as a user whose name happens to be the
|
|
||||||
// group's, and its remove button calls the user endpoint and 404s.
|
|
||||||
function isGroupDn(dn){
|
|
||||||
return /,ou=groups,/i.test(String(dn));
|
|
||||||
}
|
|
||||||
|
|
||||||
function processGroup(value){
|
|
||||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
|
||||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
|
||||||
|
|
||||||
// Split before anything else consumes `member`.
|
|
||||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
|
||||||
return {
|
|
||||||
dn: dn,
|
|
||||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
|
||||||
groupCN: value.cn
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
|
||||||
value.nestedCount = value.nested.length;
|
|
||||||
value.hasNested = value.nestedCount > 0;
|
|
||||||
|
|
||||||
// Candidates to nest: every other group not already nested here. Self is
|
|
||||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
|
||||||
// which is the only place that can see the whole graph.
|
|
||||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
|
||||||
value.toNest = allGroups.filter(function(g){
|
|
||||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
|
||||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
|
||||||
|
|
||||||
value.toAdd = userlist.filter(function(user){
|
|
||||||
return !value.member.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.toAddOwner = userlist.filter(function(user){
|
|
||||||
return !value.owner.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.member = value.member.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.owner = value.owner.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.memberCount = value.member.length;
|
|
||||||
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.groupCN = value.cn;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// app_sso_service_account is a marker group: membership hides an account
|
|
||||||
// from the Users page's People tab entirely (see users.ejs), which is
|
|
||||||
// exactly right for a non-person account but has silently made a real
|
|
||||||
// person's account look "gone" before (nothing else about it changes).
|
|
||||||
// Everywhere else in this dropdown just fires the PUT directly; only
|
|
||||||
// this one group gets a confirmation first.
|
|
||||||
function addMemberClick(event, groupCN, uid, el){
|
|
||||||
event.preventDefault();
|
|
||||||
const $el = $(el);
|
|
||||||
(async function(){
|
|
||||||
if (groupCN === 'app_sso_service_account') {
|
|
||||||
const ok = await app.messages.confirm(
|
|
||||||
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
|
||||||
$el.closest('.card'), 'warning'
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
|
||||||
await addedUser(data.message, groupCN, uid, $el);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
|
||||||
let data = await app.group.get(group);
|
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
|
||||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
|
||||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySort() {
|
|
||||||
const sort = $('#groupSort').val();
|
|
||||||
const scope = $.scope.groupCard;
|
|
||||||
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
|
|
||||||
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSearch(g) {
|
|
||||||
const q = $('#groupSearch').val().toLowerCase().trim();
|
|
||||||
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
applySort();
|
|
||||||
const groups = allGroups.filter(matchesSearch);
|
|
||||||
$.scope.groupCard.empty();
|
|
||||||
$.scope.groupCard.push(...groups);
|
|
||||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableAJAX(revealCn) {
|
|
||||||
let data = await app.group.list();
|
|
||||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
|
||||||
// it has to see the full set before the map runs -- assigning only the
|
|
||||||
// mapped result would leave every dropdown empty on first load (and one
|
|
||||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
|
||||||
allGroups = data.results;
|
|
||||||
allGroups = data.results.map(processGroup);
|
|
||||||
applyFilters();
|
|
||||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNestedClick(event, groupCN, childCN, el){
|
|
||||||
event.preventDefault();
|
|
||||||
const $card = $('#group-card-' + groupCN);
|
|
||||||
(async function(){
|
|
||||||
try {
|
|
||||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $card, 'success');
|
|
||||||
} catch(e) {
|
|
||||||
// 409 here is the cycle guard or an already-nested group -- both
|
|
||||||
// carry a specific server message worth showing verbatim.
|
|
||||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeNested(groupCN, childCN, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(
|
|
||||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
|
||||||
$item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMember(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
|
||||||
await app.api.delete(`group/${cn}`);
|
|
||||||
$.scope.groupCard.remove('cn', cn);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
userlist = (await app.user.list()).results;
|
|
||||||
tableAJAX();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="container mt-4">
|
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
|
||||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
|
||||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
|
||||||
</div>
|
|
||||||
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
|
|
||||||
<option value="name-asc">Name A → Z</option>
|
|
||||||
<option value="name-desc">Name Z → A</option>
|
|
||||||
<option value="members-desc">Most members</option>
|
|
||||||
<option value="members-asc">Fewest members</option>
|
|
||||||
</select>
|
|
||||||
<span id="groupCount" class="text-muted text-nowrap small"></span>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-object-group"></i>
|
|
||||||
Add new group
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
|
|
||||||
<div class="card shadow col">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>
|
|
||||||
<i class="fa-solid fa-arrows-down-to-people"></i>
|
|
||||||
Group: {{ cn }}
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</h5>
|
|
||||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
|
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
Members
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
|
||||||
<i class="fa-solid fa-layer-group"></i>
|
|
||||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
|
||||||
<i class="fa-solid fa-user-tie"></i>
|
|
||||||
Owners
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item float-end">
|
|
||||||
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #member }}
|
|
||||||
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /member }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
|
||||||
{{ #toAdd }}{{#.}}
|
|
||||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAdd }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
|
||||||
<p class="text-muted small mb-2">
|
|
||||||
Everyone in a nested group is a member of this one, at any depth.
|
|
||||||
</p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #nested }}
|
|
||||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
|
||||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-link-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /nested }}
|
|
||||||
{{ ^hasNested }}
|
|
||||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
|
||||||
{{ /hasNested }}
|
|
||||||
</ul>
|
|
||||||
<div class="dropdown mt-2">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
|
||||||
{{ #toNest }}
|
|
||||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
|
||||||
{{ /toNest }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #owner }}
|
|
||||||
<li class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /owner }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="dropdown float-start">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
|
|
||||||
{{ #toAddOwner }}{{#.}}
|
|
||||||
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAddOwner }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="float-end">
|
|
||||||
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
|
|
||||||
<i class="fa-solid fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Created: {{createTimestamp}}<br />
|
|
||||||
Last Modified: {{modifyTimestamp}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -206,8 +206,8 @@
|
|||||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||||
+ '<div class="card-body">'
|
+ '<div class="card-body">'
|
||||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||||
+ iconHtml
|
|
||||||
+ '<span>' + esc(r.name) + '</span>'
|
+ '<span>' + esc(r.name) + '</span>'
|
||||||
|
+ iconHtml
|
||||||
+ '</h5>'
|
+ '</h5>'
|
||||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||||
|
|||||||
@@ -656,9 +656,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="own-api-tokens-section" style="display:none">
|
<!-- Wrapped in the same `.container` as the profile/edit cards above (which
|
||||||
<div class="row mt-3 justify-content-center">
|
closes before this block): without it the API Tokens card renders
|
||||||
<div class="col-md-8">
|
full-bleed and is visibly wider than every other card on the site. -->
|
||||||
|
<div id="own-api-tokens-section" class="container" style="display:none">
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<% if(ui.profileUrl){ %>
|
<% if(ui.profileUrl){ %>
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
<div class="container mt-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="row">
|
||||||
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
|
<div class="col-12">
|
||||||
<ul class="nav nav-pills" id="vault-tabs">
|
<div class="card shadow">
|
||||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
|
||||||
</ul>
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
|
||||||
</div>
|
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
|
||||||
<div class="tab-content">
|
</ul>
|
||||||
|
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="tab-content">
|
||||||
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade show active" id="tab-secrets">
|
<div class="tab-pane fade show active" id="tab-secrets">
|
||||||
<div class="d-flex justify-content-end mb-3">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
||||||
<i class="fas fa-plus"></i> New Secret
|
<div class="d-flex align-items-center gap-2">
|
||||||
</button>
|
<a href="/docs/vault" class="text-reset" title="Vault help & documentation"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="p-3">
|
||||||
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
|
<div class="card-header"><h5 class="card-title mb-0">Secrets List</h5></div>
|
||||||
<div class="list-group list-group-flush" id="secrets-list">
|
<div class="list-group list-group-flush" id="secrets-list">
|
||||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||||
@@ -44,17 +51,20 @@
|
|||||||
<h4>Select a secret to view its details</h4>
|
<h4>Select a secret to view its details</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
||||||
<div class="tab-pane fade" id="tab-apps">
|
<div class="tab-pane fade" id="tab-apps">
|
||||||
<div class="row">
|
<div class="p-3">
|
||||||
|
<div class="row">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
<div class="card-header"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
||||||
|
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
||||||
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
||||||
@@ -66,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card shadow-sm d-none" id="app-result-card">
|
<div class="card shadow-sm d-none" id="app-result-card">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title mb-0">App token</h5>
|
<h5 class="card-title mb-0">App token</h5>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,8 +91,124 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/<name>/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
|
||||||
|
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane fade" id="tab-shared">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">My shared secrets</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush" id="shared-mine-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="card-title mb-0">Shared with me</h5></div>
|
||||||
|
<div class="list-group list-group-flush" id="shared-granted-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Shared Secret Modal -->
|
||||||
|
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">New Shared Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name (slug)</label>
|
||||||
|
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Data (JSON)</label>
|
||||||
|
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
|
||||||
|
"key": "value"
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="shared-create-error"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Grants Modal -->
|
||||||
|
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Share</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
|
||||||
|
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
|
||||||
|
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="grants-error"></div>
|
||||||
|
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Shared Secret Modal -->
|
||||||
|
<div class="modal fade" id="sharedViewModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="shared-view-title">Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +260,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
// key relative to the subject's namespace (so 'foo' for a user means
|
// key relative to the subject's namespace (so 'foo' for a user means
|
||||||
// secret/data/users/<uid>/foo).
|
// secret/data/users/<uid>/foo).
|
||||||
function vpath(kind, key) {
|
function vpath(kind, key) {
|
||||||
return `secret/${kind}/${VAULT_BASE}${key}`;
|
let cleanKey = key || '';
|
||||||
|
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
|
||||||
|
if (VAULT_BASE) {
|
||||||
|
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
|
||||||
|
}
|
||||||
|
return `secret/${kind}/${cleanKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiCall(method, path, body = null) {
|
function apiCall(method, path, body = null) {
|
||||||
@@ -156,7 +287,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
|
|
||||||
async function loadSecrets() {
|
async function loadSecrets() {
|
||||||
try {
|
try {
|
||||||
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
|
||||||
|
const res = await apiCall('GET', listPath);
|
||||||
const listEl = document.getElementById('secrets-list');
|
const listEl = document.getElementById('secrets-list');
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||||
@@ -291,16 +423,194 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
document.getElementById('app-token').textContent = result.token;
|
document.getElementById('app-token').textContent = result.token;
|
||||||
document.getElementById('app-name-display').textContent = name;
|
document.getElementById('app-name-display').textContent = name;
|
||||||
document.getElementById('app-result-card').classList.remove('d-none');
|
document.getElementById('app-result-card').classList.remove('d-none');
|
||||||
|
loadApps();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
errorEl.classList.remove('d-none');
|
errorEl.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List the minted external-app tokens (metadata only). Makes the Apps tab show
|
||||||
|
// what's been minted instead of a credential that vanishes after the once-only
|
||||||
|
// token display.
|
||||||
|
async function loadApps() {
|
||||||
|
const $list = document.getElementById('apps-list');
|
||||||
|
if (!$list) return;
|
||||||
|
$list.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/vault/apps', {
|
||||||
|
headers: { 'auth-token': app.auth.getToken() }
|
||||||
|
});
|
||||||
|
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
|
||||||
|
const { apps = [] } = await res.json();
|
||||||
|
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
|
||||||
|
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
|
||||||
|
const ok = !a.lastError;
|
||||||
|
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
||||||
|
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
||||||
|
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
||||||
|
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
||||||
|
</div>
|
||||||
|
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') + '</div>';
|
||||||
|
} catch (err) {
|
||||||
|
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyText(text) {
|
function copyText(text) {
|
||||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared secrets tab ──────────────────────────────────────────────
|
||||||
|
let currentShared = null;
|
||||||
|
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
|
||||||
|
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
|
||||||
|
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
|
||||||
|
|
||||||
|
function sharedApi(path, method = 'GET', body = null) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('/api/shared-secrets' + path, opts).then(async res => {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShared() {
|
||||||
|
try {
|
||||||
|
const res = await sharedApi('/');
|
||||||
|
const items = (res && res.items) || [];
|
||||||
|
renderSharedMine(items.filter(i => i.role === 'owner'));
|
||||||
|
renderSharedGranted(items.filter(i => i.role === 'grantee'));
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('shared-mine-list').innerHTML =
|
||||||
|
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSharedMine(items) {
|
||||||
|
const el = document.getElementById('shared-mine-list');
|
||||||
|
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
|
||||||
|
el.innerHTML = '';
|
||||||
|
items.forEach(s => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSharedGranted(items) {
|
||||||
|
const el = document.getElementById('shared-granted-list');
|
||||||
|
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
|
||||||
|
el.innerHTML = '';
|
||||||
|
items.forEach(s => {
|
||||||
|
const row = document.createElement('a');
|
||||||
|
row.href = '#';
|
||||||
|
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||||
|
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
|
||||||
|
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateSharedModal() {
|
||||||
|
currentShared = null;
|
||||||
|
document.getElementById('shared-slug-input').value = '';
|
||||||
|
document.getElementById('shared-desc-input').value = '';
|
||||||
|
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
|
||||||
|
document.getElementById('shared-create-error').classList.add('d-none');
|
||||||
|
sharedCreateModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSharedSecret() {
|
||||||
|
const err = document.getElementById('shared-create-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
|
||||||
|
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
|
||||||
|
try {
|
||||||
|
await sharedApi('/', 'POST', {
|
||||||
|
slug: document.getElementById('shared-slug-input').value.trim(),
|
||||||
|
description: document.getElementById('shared-desc-input').value.trim(),
|
||||||
|
data
|
||||||
|
});
|
||||||
|
sharedCreateModal.hide();
|
||||||
|
await loadShared();
|
||||||
|
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewShared(s) {
|
||||||
|
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
|
||||||
|
document.getElementById('shared-view-content').textContent = 'Loading...';
|
||||||
|
sharedViewModal.show();
|
||||||
|
try {
|
||||||
|
const res = await apiCall('GET', 'secret/data/' + s.path);
|
||||||
|
document.getElementById('shared-view-content').textContent =
|
||||||
|
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGrants(id) {
|
||||||
|
currentShared = id;
|
||||||
|
document.getElementById('grants-error').classList.add('d-none');
|
||||||
|
document.getElementById('grant-id-input').value = '';
|
||||||
|
sharedGrantsModal.show();
|
||||||
|
try {
|
||||||
|
const res = await sharedApi('/' + id + '/grants');
|
||||||
|
const grants = (res && res.grants) || [];
|
||||||
|
const el = document.getElementById('grants-list');
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
|
||||||
|
grants.forEach(g => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGrant() {
|
||||||
|
const err = document.getElementById('grants-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
try {
|
||||||
|
await sharedApi('/' + currentShared + '/grants', 'POST', {
|
||||||
|
granteeType: document.getElementById('grant-type-input').value,
|
||||||
|
granteeId: document.getElementById('grant-id-input').value.trim()
|
||||||
|
});
|
||||||
|
document.getElementById('grant-id-input').value = '';
|
||||||
|
openGrants(currentShared);
|
||||||
|
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeGrant(grantId) {
|
||||||
|
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
|
||||||
|
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteShared(id) {
|
||||||
|
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
|
||||||
|
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
const user = await app.auth.forceLogin();
|
const user = await app.auth.forceLogin();
|
||||||
if (!user) return; // not logged in — forceLogin redirected to /login
|
if (!user) return; // not logged in — forceLogin redirected to /login
|
||||||
@@ -312,8 +622,10 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
||||||
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
||||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||||
|
loadApps();
|
||||||
}
|
}
|
||||||
loadSecrets();
|
loadSecrets();
|
||||||
|
loadShared();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
||||||
|
//
|
||||||
|
// Simulates the agent: enrolls one, connects to the SSO WSS with its token,
|
||||||
|
// sends a real LDAP bind request as raw bytes in an `ldap_tunnel` message, and
|
||||||
|
// verifies the SSO relays it into OpenLDAP and pipes the bind response back.
|
||||||
|
// This proves the SSO side of the tunnel without needing the agent binary.
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const SSO_URL = process.env.SSO_URL || 'http://sso:3001';
|
||||||
|
const WS_URL = SSO_URL.replace(/^http/, 'ws') + '/api/agent/ws';
|
||||||
|
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||||
|
const USER_DN = 'cn=test,ou=people,dc=test,dc=local';
|
||||||
|
|
||||||
|
function fail(msg) { console.error('E2E FAIL:', msg); process.exit(1); }
|
||||||
|
|
||||||
|
async function waitForSso() {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${SSO_URL}/health`);
|
||||||
|
if (r.ok) return;
|
||||||
|
} catch (_) {}
|
||||||
|
await new Promise((res) => setTimeout(res, 1000));
|
||||||
|
}
|
||||||
|
fail('SSO never became ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const r = await fetch(`${SSO_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(TEST_CREDS),
|
||||||
|
});
|
||||||
|
if (!r.ok) fail(`login failed: ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
return body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrollAgent(authToken) {
|
||||||
|
const r = await fetch(`${SSO_URL}/api/agent/enroll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'auth-token': authToken },
|
||||||
|
body: JSON.stringify({ name: `e2e-${Date.now().toString(36)}` }),
|
||||||
|
});
|
||||||
|
if (!r.ok) fail(`enroll failed: ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
return body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple LDAP bind request (version 3) as raw BER bytes.
|
||||||
|
function buildBindRequest(dn, password) {
|
||||||
|
const dnBuf = Buffer.from(dn, 'utf8');
|
||||||
|
const pwBuf = Buffer.from(password, 'utf8');
|
||||||
|
const version = Buffer.from([0x02, 0x01, 0x03]);
|
||||||
|
const name = Buffer.concat([Buffer.from([0x04, dnBuf.length]), dnBuf]);
|
||||||
|
const simple = Buffer.concat([Buffer.from([0x80, pwBuf.length]), pwBuf]);
|
||||||
|
const bindContent = Buffer.concat([version, name, simple]);
|
||||||
|
const bindReq = Buffer.concat([Buffer.from([0x60, bindContent.length]), bindContent]);
|
||||||
|
const msgId = Buffer.from([0x02, 0x01, 0x01]);
|
||||||
|
const msgContent = Buffer.concat([msgId, bindReq]);
|
||||||
|
return Buffer.concat([Buffer.from([0x30, msgContent.length]), msgContent]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful bind response is a BindResponse (0x61) with resultCode 0 (0x0a 01 00).
|
||||||
|
function isSuccessBindResponse(buf) {
|
||||||
|
return buf.includes(Buffer.from([0x61])) && buf.includes(Buffer.from([0x0a, 0x01, 0x00]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await waitForSso();
|
||||||
|
const authToken = await login();
|
||||||
|
const agentToken = await enrollAgent(authToken);
|
||||||
|
console.log('E2E: enrolled agent, connecting WSS...');
|
||||||
|
|
||||||
|
const ws = new WebSocket(`${WS_URL}?token=${agentToken}`);
|
||||||
|
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
|
||||||
|
console.log('E2E: WSS connected');
|
||||||
|
|
||||||
|
const bindBytes = buildBindRequest(USER_DN, TEST_CREDS.password);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'ldap_tunnel',
|
||||||
|
payload: { conn_id: 'e2e-1', data: bindBytes.toString('base64') },
|
||||||
|
}));
|
||||||
|
console.log('E2E: sent bind request bytes');
|
||||||
|
|
||||||
|
const result = await new Promise((res, rej) => {
|
||||||
|
const timeout = setTimeout(() => rej(new Error('timed out waiting for bind response')), 10000);
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(data); } catch (_) { return; }
|
||||||
|
if (msg.type !== 'ldap_tunnel') return;
|
||||||
|
if (msg.payload.close) return;
|
||||||
|
const buf = Buffer.from(msg.payload.data || '', 'base64');
|
||||||
|
if (isSuccessBindResponse(buf)) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
res({ ok: true, bytes: buf.length });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('error', (e) => { clearTimeout(timeout); rej(e); });
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`E2E: got successful bind response (${result.bytes} bytes)`);
|
||||||
|
ws.close();
|
||||||
|
console.log('E2E PASS');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => fail(e.message));
|
||||||