Compare commits

...

29 Commits

Author SHA1 Message Date
wmantly 221e890897 Merge pull request #171 from theta42/fix/directory-topology-container-not-host
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 15s
Fix Directory topology: containers aren't hosts
2026-08-06 21:40:25 -04:00
wmantly 5925940936 Fix Directory topology: containers aren't hosts; bump submodules; docs
bootstrap.js no longer creates host_theta-proxy / host_theta-jump as
synthetic kind:'host' resources. Proxy and jump-host are containers running
on the one real stack host, not machines of their own -- and jump-host
resolves its SSH-reachable-hosts list from exactly kind:'host', so the
mistake wasn't just conceptual, it could offer unreachable SSH targets.
Their services now parent directly onto the stack host, like every other
component. Installs seeded between 2026-08-05 and this release self-heal on
the next ./setup.sh run: existing children are re-parented off the
synthetic hosts and the now-empty synthetic hosts are removed. Validated
live against a running instance carrying the exact bad state.

Bumps submodules to sso-manager-node v1.30.2, proxy v1.35.1,
jump-host v1.19.1.

Also: README's architecture diagram + repo layout were stale (2-service
view predating jump-host/OpenBao, 2 of 5 submodules listed); new
docs/fixtures.md + docs/screenshots.md + bootstrap/seed-demo-users.sh for
consistent, repeatable demo data and screenshot passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:38:48 -04:00
wmantly ea5d2350a4 Merge pull request #170 from theta42/fix/rollup-sso-v1.30.1
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 19s
fix: roll up sso-manager-node v1.30.1 (v1.44.0)
2026-08-06 14:45:00 -04:00
wmantly 050ff87a0b fix: roll up sso-manager-node v1.30.1 (v1.44.0)
Test Email and Test SMS could never have worked, and all SMS delivery was
broken underneath them:

- Test Email threw "Email.send is not a function" -- models/email.js
  exports {Mail} and the handler called .send on the module.
- Test SMS threw "Unexpected token '<'" -- it POSTed to
  api.voip.ms/v1.0/sms/send, which does not exist, and got HTML back.
- models/sms.js called PluginInstance.find(), but the ORM has no find, so
  every SMS threw before it could even reach the VoIP.ms fallback.

Both test endpoints now go through the same senders real messages use. A
test that reimplements delivery proves nothing, which is how two broken
paths went unnoticed.

Also: the Install Agent modal now offers the join-key flow that v1.43.0
shipped in the API and documented but never surfaced in the UI.

Tagged during a GitHub Actions major outage; verified locally on the
merged commit (299/299 in the same Docker suite CI runs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:42:45 -04:00
wmantly bc87d4e381 Merge pull request #169 from theta42/feat/agent-join-key-provisioning
CI/CD / docker-push (push) Failing after 13s
CI/CD / build-theta-agent (push) Successful in 40s
fix: agent join-key provisioning; account for the stack's own containers (v1.43.0)
2026-08-06 10:49:52 -04:00
wmantly 7efc271938 fix: agent join-key provisioning; account for the stack's own containers (v1.43.0)
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 13s
Rolls up sso-manager-node v1.30.0, theta-agent v1.5.1, proxy v1.35.0.

The stack's own theta-agent could never connect. setup.sh generated a
random token locally and wrote it into agent.yml, but the SSO only
accepts credentials it issued, so it was rejected on every attempt and
the agent looped on "close 4001: Unauthorized" forever. It now writes a
join key the SSO minted; the agent exchanges it for its own token and the
SSO public key on first connect and rewrites its own config.

agent.yml was also left holding literal REPLACE_WITH_* placeholders once
the seds stopped matching the renamed fields, so a fresh install had no
credential at all. The file is chmod 600 now that it holds one.

A fresh install presented its own five containers as unmanaged
discoveries. The compose project name is passed to the Docker discovery
plugin, which recognises them and links each to the service it
implements. openbao and bao-renewer had no directory entries for their
containers to attach to; both are seeded as services now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:48:20 -04:00
wmantly 4bf875e841 Merge pull request #168 from theta42/fix/seed-hierarchy-and-host-sso-redirect
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 14s
feat: agent enrollment, per-host SSO redirect URIs, seed hierarchy (v1.42.0)
2026-08-05 19:33:01 -04:00
wmantly 6479d35fb8 feat: agent enrollment, per-host SSO redirect URIs, seed hierarchy (v1.42.0)
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
Rolls up sso-manager-node v1.29.0, theta-agent v1.4.0, proxy v1.34.0 and
jump-host v1.19.0.

Per-host SSO returned "400 redirect_uri is not registered for this
client". The bootstrap registered only the proxy's own management
callback, but per-host SSO calls back to
https://<protected-host>/__proxy_auth/callback -- a different URL per
proxied host, all against that one OAuth client. Now registers the
wildcard + apex patterns, and backfills them onto existing clients so
upgraded stacks are fixed too.

theta-proxy and theta-jump were seeded as hosts and then left childless
while their services hung off the stack host. Services now parent to the
host that runs them; reparent() corrects existing installs, but only when
the current parent is the one the old code set.

The proxy gets a read-only SSO API token (minted before the OpenBao
snapshot so the running proxy receives it) backing the per-host SSO group
autocomplete, and the sso-broker policy grants secret/agent/* for the
SSO's persistent theta-agent signing key.

BREAKING: theta-agents must be re-enrolled, and ./setup.sh must be re-run
for the new OpenBao grant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:29:29 -04:00
wmantly 3ca4802075 fix: mount docker socket for the docker discovery plugin; roll up sso v1.28.0 + theta-agent v1.3.1 (v1.41.0) (#167)
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 17s
- docker-compose: mount /var/run/docker.sock into sso-manager so the seeded
  docker-local plugin can list containers (was ENOENT -> 'Last run: error')
- gitlinks: sso-manager-node 49100c9 (v1.28.0), theta-agent 51750d0 (v1.3.1)
2026-08-05 03:04:20 -04:00
wmantly 86026e90e7 fix: skip host self-registration when sso_token empty; roll up ldap-client v1.25.0 (v1.40.0) (#166)
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
ldap-client no longer POSTs an empty Bearer to /api/directory-admin/resources
(the misleading 'Invalid Credentials, login failed' during setup). Gitlink ->
ldap-client 68fcdb5 (v1.25.0).
2026-08-05 01:34:32 -04:00
wmantly 3354407f04 fix: publish plain LDAP (389) to the host so setup.sh can reach the directory (v1.39.0) (#165)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
docker-compose only published LDAPS (636); plain LDAP (389) was not mapped, so
the stack host's own enrollment (ldap://localhost:389) couldn't reach the LDAP
server. Now both 389 + 636 are published (bind 0.0.0.0; LDAP_BIND/LDAPS_BIND to
lock to host). README updated.
2026-08-05 00:15:12 -04:00
wmantly 84d7c96c17 fix: LDAP enrollment uses localhost (not the public domain); align SSH access groups; roll up ldap-client v1.24.0 (v1.38.0) (#164)
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 16s
- setup.sh: ldap_host defaults to localhost (the public sso.<domain> can't reach
  the 389/636 LDAP ports through NAT); overridable via CFG_LDAPS_HOST
- ldap.vars access groups + ldap-client sssd filter now reference the SSO group
  model (site_<loc>_hosts_access, site_<loc>_host_<host>_access, god_admin)
- GROUPS.md §5/§8 updated to the corrected naming
- gitlink: ldap-client ebaac18 (v1.24.0)
2026-08-04 23:30:04 -04:00
wmantly 72046a8b29 docs: group naming matches docs/GROUPS.md; roll up sso v1.27.0 (v1.37.0) (#163)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
- GROUPS.md: per-resource groups are {site}_{kind}_{name}_{level}; site carries god + site-wide only
- gitlink: sso-manager-node e8d0420 (v1.27.0)
2026-08-04 23:02:14 -04:00
wmantly 73e1cc807a fix: setup.sh ldap.vars re-run abort + drop app_super_admin; roll up sso v1.26.1 (v1.36.1) (#162)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 19s
- setup.sh: ldap.vars generation read CFG_* first-run vars (unset on re-run);
  now reads real values from sso-secrets.js, so LDAP enrollment works on re-runs
- generated ldap_access_groups now references god_admin (app_super_admin gone)
- gitlink: sso-manager-node 8db00f0 (v1.26.1)
2026-08-04 19:33:32 -04:00
wmantly a77aa8d2df feat: seed god_admin + docker plugin, fix ldap-client enrollment, roll up sso v1.26.0 + theta-agent v1.3.0 (v1.36.0) (#161)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 16s
- bootstrap: seed god_admin into the admin's groups; seed a docker-local discovery plugin
- setup.sh: generate ldap-client/ldap.vars from the stack config so LDAP enrollment works
- docs: GROUPS.md site-slug convention (verbatim, kind in resource slug)
- gitlinks: sso-manager-node 8a9de94 (v1.26.0), theta-agent 52379c2 (v1.3.0)
2026-08-04 19:10:32 -04:00
wmantly 0e78a9e282 Merge pull request #160 from theta42/release/v1.35.18
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 19s
chore: sync proxy/jump gitlinks (v1.35.18)
2026-08-04 16:54:41 -04:00
wmantly 59c5c66007 chore: sync proxy/jump gitlinks to version-tagged commits (v1.35.18)
proxy v1.33.0 + jump v1.18.0 had package.json synced to their tags; update the
gitlinks so a deploy reports matching versions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:53:38 -04:00
wmantly ec426680c3 Merge pull request #159 from theta42/release/v1.35.17
CI/CD / docker-push (push) Failing after 17s
CI/CD / build-theta-agent (push) Successful in 47s
docs: group & permission model + sso group model (v1.35.17)
2026-08-04 16:49:11 -04:00
wmantly 6fae96d977 chore: bump sso-manager-node gitlink to v1.25.0
sso v1.25.0 shipped the group & permission model + the v1.24.0 batch (Agents →
Directory, plugin modal rework, Vault restyle). Update the gitlink for the release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:48:16 -04:00
wmantly 292b67c334 docs: group & permission model spec + link (v1.35.17)
Add docs/GROUPS.md — the canonical Group & Permission Model (schema, inheritance
resolver, Directory-only management, multi-site, host-side SSSD mapping, migration)
— link it from the docs index, and note sso v1.25.0 in the changelog.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:04:30 -04:00
wmantly 72606bfc13 feat: seed theta-proxy + theta-jump as managed host resources (v1.35.16)
The bootstrap now creates theta-proxy and theta-jump as managed host-kind
resources in the Directory (matching the OAuth client identities), alongside
the existing stack host and its service entries, so a fresh install shows them
as first-class hosts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:27:28 -04:00
wmantly c28e53e505 Merge pull request #158 from theta42/fix/theta-agent-text-file-busy-v1.35.15
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
fix: stop theta-agent before overwriting binary (v1.35.15)
2026-08-04 00:34:43 -04:00
wmantly 7ae2472c62 fix: stop theta-agent before overwriting binary (v1.35.15)
cp into a running executable fails with 'Text file busy' on a re-install.
Stop the service before copying the prebuilt binary.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:32:33 -04:00
wmantly 0c4abd82be Merge pull request #157 from theta42/release/v1.35.14-token-lifecycle
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
2026-08-04 00:25:16 -04:00
wmantly c27e8c7867 feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
- theta-svc token role (periodic 768h): SSO/PROXY/JUMP_VAULT_TOKEN now minted
  through it; ensure_token renews periodic tokens on every setup.sh re-run and
  detects/revokes/re-mints valid-but-non-periodic tokens from older installs.
- bao-renewer sidecar (docker-compose): renews the three service tokens every
  12h while the stack runs.
- sso-app token role (periodic 768h) + sso-broker policy grants for
  auth/token/create/sso-app and renew/revoke/lookup-accessor.
- docs/secrets.md rewritten around the new lifecycle.
- Bump sso-manager-node gitlink to v1.23.0 (real vault-403 fix + app-token
  lifecycle).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:21:51 -04:00
wmantly 9750413178 Merge pull request #156 from theta42/release/v1.35.13-agents-page
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 18s
feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13)
2026-08-03 23:16:39 -04:00
wmantly c1a9d8f059 feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13) 2026-08-03 23:15:19 -04:00
wmantly 0edce57f80 Merge pull request #155 from theta42/fix/theta-agent-service-control-v1.35.12
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 18s
fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
2026-08-03 23:06:39 -04:00
wmantly 49b868fedb fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
setup.sh's 'full control' edit set service_control: true, but that field is a
[]string allowlist, so theta-agent failed YAML decode and crash-looped. Remove
the invalid edit; leave the operator's allowlist (or [] default = deny all).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:05:04 -04:00
19 changed files with 1766 additions and 87 deletions
+243
View File
@@ -8,6 +8,249 @@ orchestration code; see each submodule's own `CHANGELOG.md`
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md))
for what changed inside the apps it composes.
## [v1.45.0] - 2026-08-06
Rolls up **sso-manager-node v1.30.2**, **proxy v1.35.1**, **jump-host v1.19.1**.
### bootstrap.js — Directory topology fix
**`host_theta-proxy` / `host_theta-jump` were synthetic `kind: 'host'` resources that never should have existed.** "Host" means a real, independently-existing machine — something with its own OS and sshd. A Docker container backing one of this stack's own services is never that: it has no sshd, no independent network identity. Proxy and jump-host are two of this stack's five containers, running on the one real stack host — not machines of their own.
A 2026-08-05 change gave them their own `host` resources to fix their services being parented to the stack host, solving that parenting problem with the wrong tool — the correct one, `kind: 'container'`, already existed one layer below `service` (same as `sso-manager` and `openbao` already used correctly). Beyond being conceptually wrong, this had a real functional consequence: jump-host resolves its "hosts you can reach" list from exactly `kind: host` resources, so it could offer `theta-proxy`/`theta-jump` as SSH targets — machines that don't exist and can't be reached.
Fixed: `bootstrap.js` no longer creates the synthetic hosts. Proxy's and jump-host's services parent directly onto the stack host, like every other component. On an install seeded between 2026-08-05 and this release, the fix self-heals on the next `./setup.sh` run — existing children are re-parented onto the real host and the now-empty synthetic host resources are removed automatically; a fresh install never creates them.
### Docs
- `README.md`'s architecture diagram and "Repo layout" section described a stale 2-service (sso-manager + proxy) architecture from before jump-host and OpenBao existed — updated to match the (already-accurate) `docs/architecture.md`, and listed only 2 of 5 git submodules — added the rest.
- `docs/fixtures.md` (new) — the canonical demo-fixtures reference: exact users/groups/hosts for a consistent homelab/small-business demo dataset, so future screenshot passes only need to re-capture pages whose UI actually changed.
- `docs/screenshots.md` (new) — the screenshot-capture workflow, including two gotchas hit while building it: a stale-browser-cache issue with `app.modal.js`, and never touching a login form that autofills a real saved credential.
- `bootstrap/seed-demo-users.sh` (new) — idempotent script seeding the fixtures.md user/group list via direct LDAP writes matching the app's own schema.
## [v1.44.0] - 2026-08-06
Rolls up **sso-manager-node v1.30.1**.
### sso-manager-node v1.30.1
**Test Email and Test SMS could never have worked, and all SMS delivery was broken.**
- Test Email threw `Email.send is not a function`: `models/email.js` exports `{Mail}`, and the handler required the module and called `.send` on it directly.
- Test SMS threw `Unexpected token '<', "<!DOCTYPE "...`: it POSTed to `https://api.voip.ms/v1.0/sms/send`, an endpoint that does not exist. VoIP.ms's REST API is a GET against `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.
- **Every SMS was broken, not just the test.** `models/sms.js` called `PluginInstance.find({…})`, but the ORM has no `find` — the query method is `list({where})`. It threw 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. A test that reimplements delivery proves nothing about whether real delivery works — which is how two independently broken paths went unnoticed. Failures report as `400` with the underlying reason instead of an opaque `500`.
- New guard suite fails the build on any call to a non-existent ORM static, on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
**Install Agent offers the join-key flow.** v1.43.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. It now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself.
### Release note
Tagged with GitHub Actions in a major outage. CI could not run (every job failed at *Set up job* with `Failed to resolve action download info: Service Unavailable`, before reaching any test). Verified locally instead, on the exact merged commit: the full Docker suite — same LDAP + Redis service containers CI uses — passed **299/299**, plus proxy 176/176, jump-host 43/43 and theta-agent green. The Node 18/20/22 matrix was not exercised.
## [v1.43.0] - 2026-08-06
Rolls up **sso-manager-node v1.30.0**, **theta-agent v1.5.1**, **proxy v1.35.0**. Fixes what a fresh `setup.sh` install actually produced under v1.42.0.
> **No manual step to re-enroll agents.** v1.42.0 required an admin to pre-register every host. `setup.sh` now mints a **join key** and the agent enrolls itself, so installing the agent is once again all it takes to add a host.
### Fixed — theta-suite orchestration
- **The stack's own theta-agent could never connect.** `setup.sh` generated a random token locally and wrote it into `agent.yml`. The SSO only accepts credentials it issued, so that token was rejected on every attempt and the agent looped on `close 4001: Unauthorized` forever. It now writes a join key the SSO minted; the agent exchanges it for its own token and the SSO's public key on first connect and rewrites its own config.
- **`agent.yml` was left holding literal placeholders.** The `REPLACE_WITH_ISSUED_AGENT_TOKEN` / `REPLACE_WITH_SSO_PUBLIC_KEY` strings were shipped as-is when the seds no longer matched the renamed fields, so the file on a fresh install contained no credential at all. The file is also `chmod 600` now that it holds one.
- **A fresh install presented its own five containers as unmanaged discoveries** (`theta-proxy`, `theta-jump`, `sso-manager`, `bao-renewer`, `openbao`). The compose project name is now passed to the Docker discovery plugin, which recognises them as ours and links each to the service it implements.
- **`openbao` and `bao-renewer` had no directory entries**, so their containers had nothing to attach to and appeared as parentless roots. Both are seeded as services now — they are part of what the stack deploys and belong in the directory like every other component.
### Added
- The bootstrap mints a theta-agent join key and hands it to `setup.sh` (`AGENT_JOIN_KEY`), reusing the `setup`-labelled key across runs.
---
### sso-manager-node v1.30.0
**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 to pin. The join key is a bootstrap credential, never the host's identity — one key stays convenient without becoming a fleet-wide skeleton key, every host remains individually revocable, and revoking a key stops new hosts joining without touching enrolled ones.
**Collapsing the Directory tree did nothing.** `applyTreeCollapse` found the caret with `.tree-caret i` and returned early when absent — Font Awesome's SVG-with-JS mode rewrites `<i>` to `<svg>`, so that selector matched nothing and the early return skipped setting `hideBelowDepth`, meaning no row was ever hidden. State now lives on the caret button, rotated by CSS.
**Discovery Plugins.** The delete button called `deleteDiscoveryPlugin()`, which was never defined. The pane also had no `.actionMessage`, and confirmations render into one — without it the promise never settles, so an awaited confirmation hangs forever and the gated action silently never happens. Instances can now be edited (secrets shown blank rather than prefilled with the mask).
**Discovery.** Docker container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one; they now derive from compose project + service.
**Docs.** `/docs/discovery` 404'd; new `docs/discovery.md`. The `agents` slug pointed at `plugins.md`, leaving `docs/agents.md` unreachable in-app.
### theta-agent v1.5.1
- `join_key` config field, presented while `auth_token` is empty. The agent persists the issued token + public key into its own `agent.yml` — line-based, so comments, capabilities and formatting survive — and blanks the join key.
- Sends `?hostname=` so a self-enrolling host is named after itself; refuses to connect with no credential rather than presenting an empty one.
- `install.sh --join-key`.
- **v1.5.1 rebuilds the prebuilt `theta-agent-linux-amd64`.** `setup.sh` installs that committed binary rather than building from source, and the v1.5.0 one predated join-key support — it would have received a `join_key` it did not understand. Same trap as the v1.3.0 heartbeat fix.
### proxy v1.35.0
- Permission entries can be **edited**; previously only Delete existed, so changing a role meant delete-and-re-add. Because a permission's id is derived from (subjectType, subject, scope, domain), changing any of those replaces the record — the endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant conferring access.
## [v1.42.0] - 2026-08-05
Rolls up **sso-manager-node v1.29.0**, **theta-agent v1.4.0**, **proxy v1.34.0** and **jump-host v1.19.0**.
> **Breaking — re-enroll your theta-agents.** The SSO now rejects agent tokens it
> did not issue. Any agent installed before this release carries a token
> generated in the browser that the server never recorded, and will be refused
> with close code `4001` until re-enrolled from **Directory → Install Agent**.
>
> **Re-run `./setup.sh`.** The `sso-broker` OpenBao policy needs the new
> `secret/agent/*` grant, or the SSO cannot persist its agent signing key and
> will refuse every high-risk agent command.
### Fixed — theta-suite orchestration
- **Per-host SSO returned `400 redirect_uri is not registered for this client`.** The bootstrap registered only the proxy's own management callback (`https://<proxy-host>/api/auth/oidc/callback`), but per-host SSO calls back to `https://<protected-host>/__proxy_auth/callback` — a different URL for every host the proxy fronts, all against that one OAuth client. It now also registers `https://**.<domain>/__proxy_auth/callback` and the bare apex, and `ensureRedirectUris()` backfills them onto an existing client so upgraded stacks are fixed too, not just fresh installs. (The SSO's wildcard matcher already supported this; nothing was ever registered to use it.)
- **Seeded services were parented to the wrong host.** `theta-proxy` and `theta-jump` were created as host resources and then left childless, while the Proxy, OpenResty Edge and SSH Jump Host services hung off the stack host instead. They now parent to the host that runs them. `reparent()` corrects existing installs on the next run, and only when the current parent is exactly the one the old code set — a layout an operator arranged deliberately is left alone.
- The directory-edge fetch added for re-parenting is tolerated separately from the resource list, so losing it can't skip seeding the resources themselves.
### Added — theta-suite orchestration
- **The proxy gets a read-only SSO API token.** Minted by the bootstrap and written into `proxy-secrets.js` (before the OpenBao snapshot, so the running proxy actually receives it), backing the per-host SSO group autocomplete. Idempotent: only mints when `sso.apiToken` is still empty.
- `setup.sh` writes an `sso: { url, apiToken }` block into the generated `proxy-secrets.js`.
- The `sso-broker` OpenBao policy grants `secret/agent/*` for the persistent theta-agent signing key.
- `docs/secrets.md` documents the signing key, why it must be stable, and what happens when the grant is missing.
---
### sso-manager-node v1.29.0
**Security — the theta-agent channel authenticated nothing.** `/api/agent/ws` accepted any token string; there was no agent registry, because tokens were generated in the *browser* and never recorded server-side. Anyone who 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 they guessed.
- 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/revoked → close `4001`, audited.
- `POST /api/agent/enroll` mints the token server-side and returns it once; only its hash is stored. Rotate/revoke/delete drop the live socket immediately (`4004`/`4003`).
- Commands are addressed by agent **id**, never by token.
- The Ed25519 signing key was generated in the `AgentManager` constructor, so it changed on every restart and the `public_key` pinned in an agent's `agent.yml` stopped matching. It now lives in OpenBao at `secret/agent/signing-key`; if it can't be loaded the SSO refuses high-risk commands rather than signing with a key no agent has seen.
- Enroll/update/rotate/revoke/delete, every command, and every rejected connection are audited with the acting user.
**Directory & agents.** Agents bind to a host resource instead of being matched by hostname; a bound agent's discovery is written onto that resource (`discovery_sources: ["theta-agent"]`) — previously the one source running *on* the host contributed nothing. Enrollments survive restarts, so "installed but offline" (red) is now distinguishable from "no agent" (grey). The Install Agent modal enrolls first and emits `--public-key`, which was never written into `agent.yml` before.
**Directory tree.** Collapsible, with per-browser persisted state; an active search overrides collapse so matches inside folded subtrees aren't hidden.
**Discovery — found by running against a live 3-node Proxmox cluster.**
- MACs and IPs were collected into two flat lists and zipped by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC.
- A Proxmox endpoint resource now parents its nodes (one endpoint = one subtree), carrying no IP — giving it the address it's reached at made the reconciler merge it with the node answering there, producing a resource that was **its own parent**. Self-edges and cycle-closing edges are refused.
- Hosts were named after their MAC address, because `bestName` preferred the longer string. Names are ranked hostname > IP > MAC.
- `isIp` never matched anything (`\\.` in a regex literal matches a backslash, not a dot).
- Guests carry `sourceId`/`node`/`vmid`/`macAddress`; container and overlay interfaces (`docker0`, `veth*`) are filtered out; stopped VMs still report a MAC; DHCP LXCs get an address; nodes report their own IP/MAC; offline nodes are recorded rather than skipped.
- Cross-kind merges prevented; the inventory is read once per run instead of once per incoming resource.
**Other.** The Profile page's API Tokens card is no longer wider than every other card (it sat outside the page container). `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite had been failing in CI as "Cannot find module" — suites 27 → 29, 296 tests passing.
### theta-agent v1.4.0 (protocol v1.2.0)
- **Fail-closed verification.** `verifySignature` returned `true` when no `public_key` was configured — and the installer never wrote one, so a default install executed `reboot`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified**.
- **Canonicalization disagreed with the server.** Go's `encoding/json` escapes `<`, `>` and `&`; `JSON.stringify` does not. Any payload containing them failed verification — for `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). Now uses `SetEscapeHTML(false)`.
- Handles the SSO's enrollment close codes and backs off 5 minutes instead of retrying a dead credential every 5 seconds forever.
- The connect log no longer prints the URL, which carried `?token=`.
- `install.sh --public-key`, and a loud warning when none is configured.
### proxy v1.34.0
- The per-host SSO **Allowed groups** field autocompletes from the SSO directory's groups. It previously suggested only local groups — the one set of values that can never match, since the allow-list is checked against the SSO's `groups` claim. New `conf.sso` block; degrades silently when unset.
- Authenticates with `Authorization: Bearer`, not the `auth-token` header.
### jump-host v1.19.0
- **Only catalog hosts are jump targets.** The filter treated a missing `managed` flag as permission, so unpromoted discovery results — Proxmox guests, UniFi clients — appeared in the TUI picker and were accepted by the username grammar. It now mirrors the SSO Directory's own rule.
## [v1.41.0] - 2026-08-05
### Fixed
- **The Local Docker daemon discovery plugin no longer errors** — the sso-manager container had no access to the host docker socket, so the seeded `docker-local` plugin (socketPath `/var/run/docker.sock`) failed with `ENOENT` and showed "Last run: error". `docker-compose.yml` now mounts `/var/run/docker.sock` into the container. Recreate the container (`docker compose up -d sso-manager`) and hit "Run now" on the plugin.
- **theta-agent ships the rebuilt binary with the heartbeat fix** (v1.3.1, gitlink `51750d0`) — the prebuilt `theta-agent-linux-amd64` predated the v1.3.0 `heartbeat_ack` fix, so the installed agent still logged "Unknown command type: heartbeat_ack". Now rebuilt + tested.
## [v1.40.0] - 2026-08-05
### Fixed
- **No more spurious "Invalid Credentials, login failed" during LDAP enrollment** (ldap-client v1.25.0, gitlink `68fcdb5`) — `index.sh` self-registered the host in the Directory when `sso_token` was *declared but empty* (it checked `[[ -v ]]`), POSTing an empty Bearer token and getting a misleading `LDAPLoginFailed`. It now only registers with a real token; the stack host (already seeded by the bootstrap) skips registration.
- **The `cn=ldapclient` service account now shows in the SSO Users UI** — it was created as a bare `organizationalRole` (invisible to the `posixAccount` user filter) and never joined `app_sso_service_account`, so it never appeared as a service account. The bootstrap now creates it as a `posixAccount` (uid 10001, above the regular-user reserved floor) and adds it to `app_sso_service_account`; for an existing account it best-effort adds the `posixAccount` shape (auxiliary, so it can't conflict with the structural `organizationalRole`) + the group membership.
## [v1.39.0] - 2026-08-05
### Fixed
- **Plain LDAP (389) now reachable from the host** — `docker-compose.yml` published only LDAPS (636); plain LDAP (389) was deliberately not mapped, so the stack host's own enrollment (`setup.sh` → ldap-client, which configures sssd against `ldap://localhost:389` and `ldaps://localhost:636`) could not reach the directory over loopback. Both 389 and 636 are now published to the host (bind 0.0.0.0; `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to lock to the host only).
## [v1.38.0] - 2026-08-04
### Fixed
- **LDAP enrollment no longer reaches for the public domain** — `setup.sh` generated `ldap.vars` with `ldap_host` defaulting to the public SSO host (`sso.<domain>`), which the NAT/firewall blocks on the LDAP ports (389/636). It now defaults to `localhost` (the LDAP server is co-located on the stack host; `ldap_tls_reqcert=never` makes this safe), overridable with `CFG_LDAPS_HOST` for an internal hostname/IP.
- **SSH access groups match the SSO group model** (ldap-client v1.24.0) — the generated `sssd.conf` access filter and `ldap-ssh-key.sh` referenced the legacy names (`<location>_access`, `app_super_admin`); they now use `site_<location>_hosts_access` (all-hosts aggregate), `site_<location>_host_<hostname>_access`, and `god_admin`. GROUPS.md §8's example updated to match.
## [v1.37.0] - 2026-08-04
### Changed
- **Group naming corrected to match docs/GROUPS.md** — per-resource groups are `{site}_{kind}_{name}_{level}` (kind always present; a host `host_theta-env``site_local_host_theta-env_access`, a service → `site_local_app_sso-manager_access`). The spec's §3 text was updated to state this explicitly.
- **Roll up sso v1.27.0** — group names match the docs, a site carries only god + site-wide groups, duplicate group links removed, `/api/agent/*` no longer 404s, shared-secrets POST/GET fixed, Vault Apps tab lists minted tokens, discovery promote + plugin run logs fixed. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
## [v1.36.1] - 2026-08-04
### Fixed
- **`setup.sh` no longer aborts with `CFG_BASE_DN: unbound variable`** — the ldap-client `ldap.vars` generation read the CFG_* first-run vars, which `ensure_config` only derives once (it returns early on a re-run once `sso-secrets.js` exists). It now reads the real values from the operator-owned `./config/sso-secrets.js` when the CFG_* vars are unset, so LDAP enrollment works on re-runs too. The generated `ldap_access_groups` now references `god_admin` (the legacy `app_super_admin` is gone).
- **Roll up sso v1.26.1** — drops the legacy `app_super_admin`: `SUPER_ADMIN_GROUP` is now `god_admin` (nested into every resource's `_admin` group), and `docker-entrypoint.sh` no longer seeds/nests `app_super_admin`. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
## [v1.36.0] - 2026-08-04
### Added
- **`god_admin` seeded + site groups auto-provisioned** (sso v1.26.0) — `god_admin` exists from first boot; every site gets `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource groups (`{site}_{slug}_{level}`) nest into the site aggregates (the inheritance lattice now exists in LDAP, not just the resolver). See the sso changelog for the full group-model completeness + server-side naming enforcement + Directory god_admin management.
- **Docker discovery plugin configured out of the box** — the bootstrap seeds a `docker-local` plugin instance pointed at `/var/run/docker.sock`, so a fresh stack discovers its own containers into the Directory immediately (idempotent; an operator-created instance is left alone).
### Fixed
- **ldap-client enrollment no longer fails** — `setup.sh` was calling `ldap-client/index.sh`, which refuses to run without a gitignored `ldap.vars` that nothing ever created (the "ldap.vars file not found!" + "enrollment failed" you saw). It now generates `ldap-client/ldap.vars` from the stack's own config (LDAPS host, base DN, `cn=ldapclient` bind + service password, SSO URL, site name) before enrolling; an operator-provided `ldap.vars` is always kept.
- **theta-agent no longer logs `Unknown command type: heartbeat_ack`** every minute — the server's ack of the agent's own heartbeat is now silently ignored instead of falling through to the unknown-command handler (which also answered with a spurious error).
### Changed
- **Roll up sso v1.26.0 + theta-agent v1.3.0** — gitlinks point at the version-tagged commits for both submodules (sso-manager-node → 8a9de94, theta-agent → 52379c2). Full changelogs: [sso](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md), [theta-agent](https://github.com/theta42/theta-agent/blob/master/CHANGELOG.md).
## [v1.35.18] - 2026-08-04
### Changed
- **Sync proxy + jump-host gitlinks to their version-tagged commits** — proxy v1.33.0 and jump-host v1.18.0 bumped their package.json to match their tags; this release picks up those corrected gitlinks so a fresh deploy reports the matching versions.
## [v1.35.17] - 2026-08-04
### Added
- **Group & Permission Model spec** — canonical documentation of the hierarchical group schema (`god_admin`, `{site}_super_admin`, per-site/per-resource host+app `admin`/`access`/`<capability>` groups, meta `everyone`/`{site}_everyone`), the inheritance resolver, Directory-only group management, multi-site isolation, host-side SSSD GID mapping (groups are `groupOfNames`, no `gidNumber`), downstream-app consumption, and migration from the legacy `app_*` groups. See [GROUPS.md](GROUPS.html).
- **sso v1.25.0** — the resolver + schema implemented in the SSO (see its changelog); the standalone Groups page removed.
## [v1.35.16] - 2026-08-04
### Added
- **theta-proxy + theta-jump as first-class managed host resources** — the bootstrap now seeds them as managed `host`-kind resources in the Directory (in addition to the existing stack host and its service entries), so a fresh install shows them as hosts.
## [v1.35.15] - 2026-08-04
### Fixed
- **theta-agent re-install failed with "Text file busy"** — setup.sh copied the prebuilt binary over a running agent service, which cp refuses. It now stops the service before copying.
## [v1.35.14] - 2026-08-04
### Fixed
- **The recurring `/vault` 403 "permission denied" is actually dead this time — it was never a policy problem.** sso's `/api/vault` proxy declared its request hook with http-proxy-middleware **v3** syntax (`on: { proxyReq }`) while the app installs HPM **v2**, which silently ignores the unknown key — so `X-Vault-Token` was never injected and every vault call reached OpenBao unauthenticated. All the policy work of v1.35.10/v1.31.1 was correct and is unchanged; the requests just never carried a token. Ships as **sso v1.23.0** (see its changelog for the companion `fixRequestBody` header-ordering fix and the initORM schema heal that unbreaks the plugin scheduler on upgraded databases).
### Added
- **OpenBao token lifecycle — nothing expires by surprise anymore.**
- New **`theta-svc` token role** (periodic 768h): `SSO/PROXY/JUMP_VAULT_TOKEN` are now minted through it instead of as plain orphan tokens with a hard ~32-day death date. `ensure_token` renews periodic tokens on every `setup.sh` re-run and detects, revokes, and re-mints valid-but-non-periodic tokens from older installs (detection is the token's `role` — OpenBao token lookup does not expose a `period` field).
- New **`bao-renewer` sidecar** (docker-compose): renews the three service tokens every 12h while the stack runs, logging each result. Recreated on every `setup.sh` run so it always holds the current tokens.
- New **`sso-app` token role** (periodic 768h): external-app tokens minted from the vault UI go through it instead of the broker's 24h role, and sso now stores each app token's *accessor* and auto-renews it (boot + every 6h) — a downstream app's credential stays valid as long as sso runs, with no renewal code in the downstream app.
- `sso-broker` policy gained `update` on `auth/token/create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`.
- `docs/secrets.md` rewritten around the new lifecycle (roles table, renewal layers, disaster recovery).
## [v1.35.13] - 2026-08-04
### Added
- **sso v1.22.0** — new **Agents** page (live list of connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry + online status) and a security fix gating the `/api/agent` REST routes. Bumped the sso-manager-node gitlink to v1.22.0.
## [v1.35.12] - 2026-08-04
### Fixed
- **theta-agent crash-looped (`cannot unmarshal !!bool 'true' into []string`)** — setup.sh's "full control" edit wrote `service_control: true`, but that field is a `[]string` allowlist, so the agent failed to decode the config and restart-loop. Removed the invalid edit; `service_control` now stays as its allowlist (default `[]` = deny all) and the operator can list specific services.
## [v1.35.11] - 2026-08-04
### Fixed
+52 -33
View File
@@ -44,29 +44,40 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
-**Phases 2-5**: Pending (see [roadmap](#)).
```
┌──────────────────────────────────────────────┐
│ your browser / apps
└───────────────┬──────────────────────────────┘
│ https
┌─────────▼─────────┐
proxyOpenResty :80/:443/:4443
│ (OIDC + LDAP) │ mgmt app :3000 (localhost)
└─────────┬─────────┘ bundled redis
┌─────────────┼──────────────────────┐
│ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo
▼ ▼
──────────────────────────┐
│ sso-manager │◄────────────────┘
│ OIDC provider + OpenLDAP │ bundled redis
│ web UI :3001 (localhost) │
ldaps :636 (LAN clients)
└───────────────────────────┘
──────────────────────────────────────────────────────────┐
browser / OIDC apps │ SSH clients │ Linux hosts
│ │ │ (PAM/SSSD, sudo, keys) │
└────────┬────────────┴──────┬──────┴───────────┬───────────┘
https (:443) ssh (:2222) ldaps (:636)
┌────────▼────────┐ ┌──────────▼────────┐ │
│ proxy │ │ jump-host │ │
│ OpenResty │ │ sshd :2222 │ │
│ :80/:443/:4443 │ │ web UI :3002 │ │
│ mgmt app :3000 └────────┬──────────┘
─────────────────┘ │ OIDC + LDAP
│ http:3001 (internal)│ via sso-manager │
▼ ▼ ▼
┌───────────────────────────────────────────────────────┐
sso-manager (Express + OpenLDAP + Redis)
│ OIDC provider + LDAP directory │
│ web UI :3001 (internal) ldaps :636 (published) │
└───────────────────────────────────────────────────────┘
▲ loads secrets at boot (scoped token each)
┌───────────┴───────────────────┐
│ openbao (KV-v2 at secret/) │ ← central secrets store
│ :8200 (internal) │ per-user + per-app KV
│ :8080 (operator UI/API) │
└───────────────────────────────┘
```
The proxy fronts the SSO Manager UI under TLS and protects it with OIDC login.
It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). Legacy apps can still bind to LDAPS on the SSO
directly.
The proxy fronts the SSO Manager UI (and the jump-host web UI) under TLS and
protects them with OIDC login. It is **both** an OIDC client of the SSO (for
login) **and** a direct LDAP client (for user lookups). Legacy apps can still
bind to LDAPS on the SSO directly. See
[docs/architecture.md](docs/architecture.md) for the full diagram (ports,
secrets flow, jump-host, ldap-client) and [docs/secrets.md](docs/secrets.md)
for the OpenBao model.
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
@@ -129,12 +140,15 @@ see browser warnings.)
Optional extra ports (only if you need them):
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
- **636** (LDAPS) — for direct-LDAP clients on other machines (Linux hosts
via PAM/SSSD, LDAP-native apps). The proxy itself reaches LDAP over the
internal Docker network, so you do **not** need to expose 636 for the stack
to work.
**Do not forward 636 to the public internet.** If you need LAN clients to bind
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
enrollment (`setup.sh` → ldap-client) configures its sssd against
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
bind over either; the proxy itself reaches LDAP over the internal Docker
network and doesn't need them.
**Do not forward 389/636 to the public internet.** If you need LAN clients to
bind LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
SAN. The default shows the public SSO hostname, which implies a public route.
@@ -270,17 +284,19 @@ for details.
## Logs
The stack runs under Docker Compose with two services — `sso-manager` and
`proxy`. Both the Node app and, for the SSO, OpenLDAP write to the container's
stdout/stderr, so `docker compose logs` is the primary view.
The stack runs under Docker Compose with several services — `sso-manager`,
`proxy`, `jump-host`, and `openbao` (plus its `bao-renewer` sidecar). Both the
Node app and, for the SSO, OpenLDAP write to the container's stdout/stderr, so
`docker compose logs` is the primary view.
```bash
# Follow both services live
# Follow all services live
docker compose logs -f
# One service
docker compose logs -f sso-manager
docker compose logs -f proxy
docker compose logs -f jump-host
# Last 200 lines and keep following
docker compose logs --tail=200 -f proxy
@@ -470,12 +486,15 @@ exactly in the bootstrap) so the SSO can verify them on bind.
theta-suite/
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
├── config.example/ # committed annotated config templates (copy to ./config/)
├── docker-compose.yml # sso-manager + proxy on one bridge net
├── docker-compose.yml # sso-manager + proxy + jump-host + openbao on one bridge net
├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups)
├── bootstrap/
│ └── bootstrap.js # runs in the sso-manager container
├── sso-manager-node/ # git submodule
── proxy/ # git submodule
── proxy/ # git submodule
├── jump-host/ # git submodule
├── ldap-client/ # git submodule (enrolls Linux hosts; also the opt-in ldap-test-host fixture)
└── theta-agent/ # git submodule
```
`./setup.sh` reads the gitignored `setup.env` on first run to generate the
+389 -19
View File
@@ -84,12 +84,38 @@ const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET
&& !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET);
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
// Per-host SSO (proxy routes/host_auth.js) calls back to
// `https://<proxied-host>/__proxy_auth/callback` — a DIFFERENT URL for every
// host the proxy fronts, all against this one OAuth client. Registering just
// REDIRECT_URI above is what produced "400 redirect_uri is not registered for
// this client" the moment a host's auth was set to SSO. The SSO's
// redirectUriAllowed() supports `**` (any number of labels), so one pattern
// covers the whole domain; `**.` does not match the bare apex, so register that
// separately for a host served at the domain itself.
//
// A function, not a const: DOMAIN is declared further down this file, so
// evaluating it here at module scope would hit the temporal dead zone.
function proxyRedirectUris() {
if (!DOMAIN) return [REDIRECT_URI];
return [
REDIRECT_URI,
`https://**.${DOMAIN}/__proxy_auth/callback`,
`https://${DOMAIN}/__proxy_auth/callback`,
];
}
const SSO_INTERNAL = 'http://localhost:3001';
const CLIENT_NAME = 'theta-proxy';
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin'];
// god_admin is the global super group (docs/GROUPS.md §2); the bootstrapped
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the
// per-console admin groups still used by the SSO UI. god_admin is nested into
// the app_sso_* groups (and every resource's _admin group) by
// docker-entrypoint.sh + api_directory_admin, so LDAP-level consumers (SSSD,
// sudo) resolve it transitively.
const ADMIN_GROUPS = ['god_admin', 'app_sso_admin', 'app_sso_oauth_admin'];
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
@@ -172,31 +198,88 @@ function ldapModify(ldif) {
}
// ── 1. LDAP service account for the proxy ───────────────────────────────────
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
// UI as a service account it must (a) match the user filter (posixAccount) and
// (b) be a member of app_sso_service_account (that membership is what the Users
// page marks as a non-person/service account). Older bootstraps created it as a
// bare organizationalRole (invisible to the Users list) and never joined the
// group, so it never appeared. Both are fixed here; the existing-path shape add
// is best-effort so a pre-existing account still binds even if the upgrade add
// fails.
function ensureServiceAccount() {
const pw = hashPasswordSSHA512(SVC_PASS);
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
if (entryExists(SVC_DN)) {
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
const r = ldapModify([
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
// Add the auxiliary posixAccount objectClass + required attrs so the entry
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
// it is structural and would conflict with the existing organizationalRole.
const shape = [
`dn: ${SVC_DN}`,
'changetype: modify',
'add: objectClass',
'objectClass: posixAccount',
'-',
'add: uid',
'uid: ldapclient',
'-',
'add: uidNumber',
`uidNumber: ${uidNum}`,
'-',
'add: gidNumber',
`gidNumber: ${uidNum}`,
'-',
'add: homeDirectory',
'homeDirectory: /nonexistent',
'-',
'add: description',
'description: LDAP bind service account (proxy / ldap-client)',
'',
].join('\n');
const rs = ldapModify(shape);
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
log(' service-account shape warning (account still binds):', rs.stderr.trim());
}
const rp = ldapModify([
`dn: ${SVC_DN}`,
'changetype: modify',
'replace: userPassword',
`userPassword: ${pw}`,
'',
].join('\n'));
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
return;
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
} else {
log(`Creating service account ${SVC_DN}`);
const entry = [
`dn: ${SVC_DN}`,
'objectClass: inetOrgPerson',
'objectClass: posixAccount',
'objectClass: top',
'cn: ldapclient',
'sn: ldapclient',
'uid: ldapclient',
`uidNumber: ${uidNum}`,
`gidNumber: ${uidNum}`,
'homeDirectory: /nonexistent',
'description: LDAP bind service account (proxy / ldap-client)',
`userPassword: ${pw}`,
'',
].join('\n');
const r = ldapAdd(entry);
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
}
log(`Creating service account ${SVC_DN}`);
const r = ldapAdd([
`dn: ${SVC_DN}`,
'objectClass: organizationalRole',
'objectClass: simpleSecurityObject',
'objectClass: top',
'cn: ldapclient',
`userPassword: ${pw}`,
// Mark it as a service account (the Users UI's service-account signal).
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
const rm = ldapModify([
`dn: ${gdn}`,
'changetype: modify',
'add: member',
`member: ${SVC_DN}`,
'',
].join('\n'));
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
}
// ── 2. First admin user ─────────────────────────────────────────────────────
@@ -275,7 +358,7 @@ async function listClients(token) {
}
async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: proxyRedirectUris() };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
@@ -299,6 +382,29 @@ async function createClient(token, opts) {
return { id, secret };
}
// Add any redirect_uris the client is missing, keeping whatever the operator
// has already registered. Backfills installs whose proxy client was created
// before the per-host `__proxy_auth/callback` patterns existed — without this,
// setting a host's auth to SSO fails with "400 redirect_uri is not registered
// for this client" on an upgraded stack and only works on a fresh one.
// Warn-only: a stack that cannot widen its client is still a working stack.
async function ensureRedirectUris(token, client, wanted) {
const have = client.redirect_uris || [];
const missing = wanted.filter((u) => !have.includes(u));
if (!missing.length) return;
try {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${client.client_id}`, {
method: 'PUT',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_uris: [...have, ...missing] }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
log(` OAuth client ${client.name}: registered ${missing.length} redirect URI(s) for per-host SSO`);
} catch (error) {
log(` WARNING: could not add redirect URIs to ${client.name}: ${error.message}`);
}
}
async function rotateClient(token, id) {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
method: 'POST',
@@ -362,6 +468,18 @@ async function dirPut(token, path, body) {
return res.json();
}
async function dirDelete(token, path) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'DELETE',
headers: { 'auth-token': token },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DELETE /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
// The site the stack registers itself under. Also the default "Location
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
@@ -381,6 +499,30 @@ const HOST_FACTS = {
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Tolerated separately from the resource list: edges only drive the
// re-parent + OAuth-link steps, and losing those is not a reason to skip
// seeding the resources themselves.
let edges = [];
try { edges = ((await dirGet(token, 'edges')).results) || []; }
catch (e) { log(` WARNING: could not list directory edges (${e.message}) — skipping re-parent/link steps`); }
// Move an already-seeded resource under the parent it should have had.
// Only ever corrects a parent this bootstrap itself seeded wrongly (the
// proxy/jump services were parented to the stack host instead of to
// host_theta-proxy / host_theta-jump); an operator who has deliberately
// re-parented something keeps their layout, because we only rewire when the
// current parent is the one the old code would have set.
async function reparent(resource, wantParentId, fromParentId) {
if (!resource || !wantParentId || !fromParentId) return;
const current = edges.find((e) => e.childId === resource.id && (e.relation === 'hosts' || e.relation === 'oauth'));
if (!current) return; // unparented: leave it alone
if (current.parentId === wantParentId) return; // already correct
if (current.parentId !== fromParentId) return; // operator moved it: respect that
// PUT with kind + hostId is what makes the route rewire the parent edge.
await dirPut(token, `resources/${resource.id}`, { kind: resource.kind, hostId: wantParentId });
current.parentId = wantParentId;
log(` directory: re-parented ${resource.kind} '${resource.slug}' onto its own host`);
}
// Create a resource unless its slug (or a legacy alternate from an earlier
// seed layout) already exists. On an existing resource, seed metadata keys
@@ -430,6 +572,25 @@ async function seedDirectory(token, clientId, jumpClientId) {
sshPort: 22,
managed: true,
}, ['stack-host']);
// "Host" means a real, independently-existing machine — something with its
// own OS and sshd, that theta-agent or a directory-aware tool like the jump
// host could actually reach on its own. A Docker container backing one of
// this stack's own services is never that, no matter how convenient it'd be
// to group its services under a host-shaped node in the UI: it has no sshd,
// no independent network identity, nothing jump-host could honestly offer
// as an SSH target. Proxy and jump-host are two of this stack's five
// containers, running on the one real host above (`host`) — not machines of
// their own. Briefly (2026-08-05 through the next release) this file seeded
// `host_theta-proxy` / `host_theta-jump` as first-class `kind: 'host'`
// resources to fix their services being parented to the stack host; that
// solved the parenting problem with the wrong tool. The right tool already
// existed: `kind: 'container'` (see seedPlugins' Docker discovery, which
// already attaches `docker-theta-suite-proxy` etc. under these services
// correctly) sits one layer below `service`, same as `sso-manager` and
// `openbao` already do. So: no synthetic hosts — Proxy's and jump-host's
// services parent directly onto the stack host, same as everything else.
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
address: `https://${SSO_HOST}`,
port: 3001,
@@ -440,7 +601,8 @@ async function seedDirectory(token, clientId, jumpClientId) {
requestable: false,
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
// in the stack actually flows through (80/443). Two faces, two entries, both
// parented directly to the stack host — see the "Host means..." note above.
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
address: `https://${PROXY_HOST}`,
port: 3000,
@@ -480,6 +642,25 @@ async function seedDirectory(token, clientId, jumpClientId) {
requestable: false,
});
// OpenBao and its renewer sidecar are part of what the stack deploys, so
// they belong in the directory like every other component. Without entries
// their containers had nowhere to attach and showed up as parentless
// discoveries on a fresh install.
await ensure('service', 'OpenBao', 'openbao', host.id, {
address: 'http://openbao:8200',
port: 8200,
subType: 'vault',
icon: 'mdi:safe',
tagline: 'Secrets store for the stack.',
requestable: false,
});
await ensure('service', 'Bao Renewer', 'bao-renewer', host.id, {
subType: 'sidecar',
icon: 'mdi:autorenew',
tagline: 'Renews the stack service tokens against OpenBao.',
requestable: false,
});
// SSH jump host service (core component — always registered).
let jumpSvc = null;
{
@@ -495,16 +676,50 @@ async function seedDirectory(token, clientId, jumpClientId) {
});
}
// Correct installs seeded between 2026-08-05 and this release, where Proxy's
// and jump-host's services were parented to now-removed synthetic
// `host_theta-proxy` / `host_theta-jump` resources instead of the stack
// host. Look them up by slug (never created going forward) rather than
// `ensure`-ing them back into existence: on any install that never had
// them, or already got corrected, this is a no-op.
const proxyHostRes = resources.find((r) => r.slug === 'host_theta-proxy');
const jumpHostRes = resources.find((r) => r.slug === 'host_theta-jump');
if (proxyHostRes) {
await reparent(psvc, host.id, proxyHostRes.id);
await reparent(resources.find((r) => r.slug === 'openresty'), host.id, proxyHostRes.id);
}
if (jumpHostRes) {
await reparent(jumpSvc, host.id, jumpHostRes.id);
}
// Once childless, the synthetic host itself is dead weight from this file's
// own earlier mistake — never something an operator would hand-create at
// these exact reserved slugs — so remove it. DELETE /resources/:id clears
// its own edges first, so this is safe now that the reparents above have
// already moved the real children off of it.
async function removeIfChildless(resource, label) {
if (!resource) return;
const stillHasChildren = edges.some((e) => e.parentId === resource.id);
if (stillHasChildren) {
log(` directory: '${label}' still has children after reparenting — leaving it for now`);
return;
}
await dirDelete(token, `resources/${resource.id}`);
log(` directory: removed now-empty synthetic host '${label}'`);
}
await removeIfChildless(proxyHostRes, 'host_theta-proxy');
await removeIfChildless(jumpHostRes, 'host_theta-jump');
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
edges.push({ parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
@@ -512,6 +727,68 @@ async function seedDirectory(token, clientId, jumpClientId) {
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// ── Plugin instances ────────────────────────────────────────────────────────
// Seed a sensible default set of plugin instances so the stack is usable the
// moment it boots, without the operator having to add them by hand. The setup
// stack runs on Docker, so the single biggest win is a Docker discovery plugin
// pointed at the local daemon socket: containers that make up the stack (and
// any others on the host) get discovered into the Directory automatically.
// Idempotent per slug: an instance an operator already created is left alone.
async function seedPlugins(token) {
async function pluginGet(path) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/plugins/${path} failed (${res.status})`);
return res.json();
}
async function pluginPost(body) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST /api/plugins failed (${res.status}): ${text}`);
}
return res.json();
}
async function ensurePlugin({ pluginType, name, slug, config }) {
const existing = ((await pluginGet('')).results) || [];
if (existing.some((i) => i.slug === slug)) {
log(` plugins: '${slug}' exists — keeping`);
return;
}
await pluginPost({ pluginType, name, slug, config });
log(` plugins: created '${slug}' (${pluginType})`);
}
try {
// The Docker daemon the setup stack itself runs under. The socket must be
// mounted into the sso container for discovery to reach it; if it isn't,
// discovery simply errors non-fatally until it is.
await ensurePlugin({
pluginType: 'docker',
name: 'Local Docker daemon',
slug: 'docker-local',
config: {
socketPath: '/var/run/docker.sock',
// Containers in our own compose project are the stack itself --
// already seeded as services above. Telling the plugin which
// project that is lets it mark them managed and attach them to
// the service they implement, instead of a fresh install
// presenting its own five containers as unmanaged discoveries.
stackProject: process.env.COMPOSE_PROJECT_NAME || 'theta-suite',
hostSlug: HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : '',
},
});
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
// (which reads that file) can use them. Only the clientId/clientSecret lines
// are touched; the rest of the file (operator edits, comments) is preserved.
@@ -542,6 +819,78 @@ function writeProxyCreds(id, secret) {
}
}
// The proxy needs a read-only SSO API token so its per-host SSO allow-list can
// suggest the directory's actual groups (otherwise the "Allowed groups" field
// autocompletes from the proxy's local groups only, which for an SSO-gated host
// is never what the operator wants). Idempotent: only mints when the file's
// `sso.apiToken` is still empty, and only rewrites that one line. Warn-only —
// no token just means no suggestions.
const PROXY_TOKEN_NAME = 'theta-proxy';
async function ensureProxyApiToken(token) {
const path = '/config/proxy-secrets.js';
let src;
try {
src = fs.readFileSync(path, 'utf8');
} catch (e) {
log(` WARNING: cannot read ${path} to add an SSO API token (${e.message})`);
return;
}
// An `sso: { ... apiToken: 'sso_...' }` already present means we're done.
if (/apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src)) {
log(' proxy already has an SSO API token — keeping');
return;
}
if (!/\bsso:\s*\{/.test(src)) {
log(` WARNING: ${path} has no \`sso\` block — add one with url + apiToken to enable SSO group autocomplete`);
return;
}
try {
const apiToken = await mintApiToken(token, PROXY_TOKEN_NAME, 'theta-suite proxy (auto-registered)');
// Replace the apiToken line inside the sso block only. The jump host's
// token lives in a different file, so an unanchored match is safe here.
const updated = src.replace(/(apiToken:\s*)(['"])[^'"]*\2/, `$1$2${apiToken}$2`);
if (updated === src) {
log(` WARNING: could not locate apiToken in ${path} — set sso.apiToken manually`);
return;
}
fs.writeFileSync(path, updated);
log(` Minted SSO API token for the proxy and wrote it into ${path}`);
} catch (e) {
log(` WARNING: could not provision the proxy's SSO API token: ${e.message}`);
}
}
// Mint (or reuse) a theta-agent join key and hand it to setup.sh.
//
// A join key is the single credential an operator needs to add a host: the
// agent presents it, the SSO enrolls the host and issues it its own per-agent
// token + public key, which the agent writes back into its agent.yml. Without
// this, adding a host meant pre-registering it in the SSO and copying two
// values onto the machine by hand -- and setup.sh's own agent install had no
// way to produce a token the server would accept at all.
//
// Idempotent: reuses the existing `setup` key rather than piling up new ones.
// A key can only be shown once, so if the stored one is not recoverable we mint
// a replacement and label it for the run that created it.
async function ensureAgentJoinKey(token) {
try {
const res = await fetch(`${SSO_INTERNAL}/api/agent/join-keys`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ label: 'setup' }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
const data = await res.json();
if (!data.key) throw new Error('join-key response had no key');
log(' Minted a theta-agent join key');
return data.key;
} catch (error) {
log(` WARNING: could not mint a theta-agent join key: ${error.message}`);
return '';
}
}
// ── 6. Provision the SSH jump host ─────────────────────────────────────────
// The jump host is a core component (always provisioned). It needs: a directory
// API token (to resolve which hosts a user may reach), an LDAP bind account
@@ -558,11 +907,11 @@ const JUMP_TOKEN_NAME = 'theta-jump-host';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) {
async function mintApiToken(token, name, description) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: 'theta-suite jump host (auto-registered)' }),
body: JSON.stringify({ name, description: description || 'theta-suite jump host (auto-registered)' }),
});
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
const data = await res.json();
@@ -697,6 +1046,10 @@ async function provisionJumpHost(token) {
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
// Widen an existing client before any of the branches below return: a
// freshly created one already gets these from createClient().
if (client) await ensureRedirectUris(token, client, proxyRedirectUris());
if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) {
// File creds match an existing client — trust the file's secret
// (it's bcrypt-hashed server-side, so we can't verify, but the proxy
@@ -726,6 +1079,11 @@ async function provisionJumpHost(token) {
resolvedClientId = id;
}
// Must run before the baoPut below: that snapshots proxy-secrets.js into
// OpenBao, and the proxy loads its conf from there at boot, so a token
// written after the snapshot would never reach the running proxy.
await ensureProxyApiToken(token);
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
// fresh: writeProxyCreds rewrote the file out from under the cached
@@ -752,6 +1110,9 @@ async function provisionJumpHost(token) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
// The agent join key setup.sh writes into /etc/theta42/agent.yml.
out('AGENT_JOIN_KEY', await ensureAgentJoinKey(token));
// Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue.
try {
@@ -761,6 +1122,15 @@ async function provisionJumpHost(token) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
// Seed default plugin instances (Docker discovery) — same warn-and-go
// policy; a stack without plugins is still usable.
try {
log('Seeding default plugins...');
await seedPlugins(token);
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
log('Done.');
process.exit(0);
} catch (e) {
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# seed-demo-users.sh — Seed realistic homelab/small-business demo users +
# groups into the SSO Manager's LDAP directory, for screenshots/demos.
#
# Mirrors the schema sso-manager-node's addLdapUser/addGroup actually write
# (see nodejs/models/user_ldap.js, group_ldap.js) so accounts created here are
# indistinguishable from ones created through the UI. Idempotent: safe to
# re-run, existing entries are skipped.
#
# Usage (from theta-env/):
# docker compose exec -T sso-manager bash /bootstrap/seed-demo-users.sh
#
# Reads the real LDAP bind DN/password out of the mounted /config/sso-secrets.js
# at runtime rather than hardcoding them, so it keeps working if secrets rotate.
set -euo pipefail
LDAP_URL="ldap://localhost:389"
BIND_DN=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindDN)")
BIND_PW=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindPassword)")
BASE_DN=$(node -e "console.log(require('/config/sso-secrets.js').stack.ldapBaseDn)")
PEOPLE_OU="ou=people,${BASE_DN}"
GROUPS_OU="ou=groups,${BASE_DN}"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
ldap_exists() {
ldapsearch -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -b "$1" -s base '(objectClass=*)' >/dev/null 2>&1
}
hash_password() {
node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('$1').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
"
}
# create_person <uid> <sn> <given_name> <mail> <uidNumber> <password> [description]
create_person() {
local uid="$1" sn="$2" given="$3" mail="$4" uidnum="$5" pass="$6" desc="${7:-}"
local person_dn="cn=${uid},${PEOPLE_OU}"
local group_dn="cn=${uid},${GROUPS_OU}"
if ldap_exists "$person_dn"; then
info "User '${uid}' already exists — skipping"
return 0
fi
local hash; hash=$(hash_password "$pass")
local tmp; tmp=$(mktemp)
trap 'rm -f "$tmp"' RETURN
cat > "$tmp" <<LDIF
dn: ${group_dn}
objectClass: posixGroup
objectClass: top
cn: ${uid}
gidNumber: ${uidnum}
description: Personal group for ${uid}
dn: ${person_dn}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: sudoRole
objectClass: ldapPublicKey
objectClass: top
objectClass: theta42Person
cn: ${uid}
sn: ${sn}
givenName: ${given}
uid: ${uid}
uidNumber: ${uidnum}
gidNumber: ${uidnum}
homeDirectory: /home/${uid}
loginShell: /bin/bash
mail: ${mail}
userPassword: ${hash}
description: ${desc:- }
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${uid}
LDIF
ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -f "$tmp"
info "Created user '${uid}' (${mail})"
}
# create_group <cn> <owner_dn> <description>
create_group() {
local cn="$1" owner_dn="$2" desc="$3"
local group_dn="cn=${cn},${GROUPS_OU}"
if ldap_exists "$group_dn"; then
info "Group '${cn}' already exists — skipping"
return 0
fi
ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" <<LDIF
dn: ${group_dn}
objectClass: groupOfNames
objectClass: top
cn: ${cn}
description: ${desc}
member: ${owner_dn}
LDIF
info "Created group '${cn}'"
}
# add_member <group_cn> <user_dn>
add_member() {
local cn="$1" user_dn="$2"
local group_dn="cn=${cn},${GROUPS_OU}"
ldapmodify -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" 2>/dev/null <<LDIF || true
dn: ${group_dn}
changetype: modify
add: member
member: ${user_dn}
LDIF
}
info "Waiting for LDAP at ${LDAP_URL}..."
for i in $(seq 1 30); do
ldapsearch -x -H "$LDAP_URL" -b '' -s base '(objectClass=*)' >/dev/null 2>&1 && break
[ "$i" -eq 30 ] && { error "LDAP not reachable"; exit 1; }
sleep 1
done
# ── Demo users (homelab / small-business cast) ───────────────────────────────
# uidNumbers start at 5000 to stay well clear of the app's own auto-assigned
# range (nextPosixId scans existing entries and increments from the highest).
# See docs/fixtures.md for the canonical list this mirrors — update both
# together.
create_person schen Chen Sarah sarah.chen@laptop-dev.vm42.us 5000 'DemoPass123!' 'Engineering — DevOps lead'
create_person dkim Kim David david.kim@laptop-dev.vm42.us 5001 'DemoPass123!' 'Engineering — Backend developer'
create_person ppatel Patel Priya priya.patel@laptop-dev.vm42.us 5002 'DemoPass123!' 'Engineering — Frontend developer'
create_person mjohnson Johnson Marcus marcus.johnson@laptop-dev.vm42.us 5003 'DemoPass123!' 'Finance — Finance manager'
create_person lnguyen Nguyen Linda linda.nguyen@laptop-dev.vm42.us 5004 'DemoPass123!' 'Finance — Bookkeeper'
create_person erodriguez Rodriguez Emily emily.rodriguez@laptop-dev.vm42.us 5005 'DemoPass123!' 'Support — Support lead'
create_person tbaker Baker Tom tom.baker@laptop-dev.vm42.us 5006 'DemoPass123!' 'Support — Support tech'
create_person jwilson Wilson James james.wilson@laptop-dev.vm42.us 5007 'DemoPass123!' 'Management — Owner'
create_person svc-monitoring Bot monitoring monitoring@laptop-dev.vm42.us 5008 'ServiceAcct!2024' 'Service account — Grafana/Prometheus scraping'
create_person svc-backup Bot backup backup@laptop-dev.vm42.us 5009 'ServiceAcct!2024' 'Service account — backup automation'
# ── Department groups (groupOfNames — what shows up in Directory > Groups) ──
ADMIN_DN="cn=admin,${PEOPLE_OU}"
create_group engineering "$ADMIN_DN" "Engineering team"
create_group finance "$ADMIN_DN" "Finance and accounting"
create_group support "$ADMIN_DN" "Support and operations"
create_group management "$ADMIN_DN" "Company management"
add_member engineering "cn=schen,${PEOPLE_OU}"
add_member engineering "cn=dkim,${PEOPLE_OU}"
add_member engineering "cn=ppatel,${PEOPLE_OU}"
add_member finance "cn=mjohnson,${PEOPLE_OU}"
add_member finance "cn=lnguyen,${PEOPLE_OU}"
add_member support "cn=erodriguez,${PEOPLE_OU}"
add_member support "cn=tbaker,${PEOPLE_OU}"
add_member management "cn=jwilson,${PEOPLE_OU}"
# Mark the service accounts as service accounts (app_sso_service_account is
# seeded by the app itself on boot, so it should already exist).
if ldap_exists "cn=app_sso_service_account,${GROUPS_OU}"; then
add_member app_sso_service_account "cn=svc-monitoring,${PEOPLE_OU}"
add_member app_sso_service_account "cn=svc-backup,${PEOPLE_OU}"
else
info "app_sso_service_account group not found — skipping service-account tagging"
fi
info "Demo data seed complete."
+50 -6
View File
@@ -52,12 +52,16 @@ services:
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
# in sso-secrets.js) and do NOT forward 636 to the public internet.
- "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
# against ldap://localhost and ldaps://localhost, and the LDAP server is
# co-located on this host, so BOTH ports must be reachable from the host
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
environment:
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
@@ -77,6 +81,10 @@ services:
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# The host docker socket so the bundled Docker discovery plugin (seeded as
# 'docker-local' with socketPath /var/run/docker.sock) can list containers.
# Without this the plugin errors with ENOENT and shows 'Last run: error'.
- /var/run/docker.sock:/var/run/docker.sock
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
# can write the generated OAuth client creds into proxy-secrets.js. The
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
@@ -238,6 +246,42 @@ services:
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
# Renews the three periodic service tokens (theta-svc role, 768h period)
# every 12h. Periodic tokens live forever ONLY while something renews them —
# this sidecar is that something, so the stack survives arbitrarily long
# uptimes and the tokens in .env never silently expire. If a token is missing
# or already dead it just logs and moves on (setup.sh re-mints on next run).
bao-renewer:
image: quay.io/openbao/openbao:latest
container_name: bao-renewer
restart: unless-stopped
depends_on:
- openbao
environment:
- BAO_ADDR=http://openbao:8200
- SSO_VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
- PROXY_VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
- JUMP_VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
entrypoint: ["/bin/sh", "-c"]
command:
- |
renew() {
if [ -z "$$2" ]; then return 0; fi
if BAO_TOKEN="$$2" bao token renew > /dev/null 2>&1; then
echo "[bao-renewer] renewed $$1"
else
echo "[bao-renewer] FAILED to renew $$1 (expired/revoked? re-run setup.sh to re-mint)"
fi
}
while true; do
renew SSO_VAULT_TOKEN "$$SSO_VAULT_TOKEN"
renew PROXY_VAULT_TOKEN "$$PROXY_VAULT_TOKEN"
renew JUMP_VAULT_TOKEN "$$JUMP_VAULT_TOKEN"
sleep 43200
done
networks:
- theta-net
openbao:
image: quay.io/openbao/openbao:latest
container_name: openbao
+323
View File
@@ -0,0 +1,323 @@
---
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.
- **The `S` site segment is the site resource's slug verbatim** (`site_local`),
NOT re-slugified (which would corrupt the delimiter: `site_local``site-local`).
- **Per-resource groups are `{S}_{kind}_{name}_{level}`.** `kind` is `host` or
`app`; `name` is the resource's **name slug with the kind prefix stripped** — a
host resource `host_theta-env` has name `theta-env`, so its groups are
`site_local_host_theta-env_access` / `_admin`. A service (the group model's
`app`, docs §11) `sso-manager` gives `site_local_app_sso-manager_access`. The
kind segment is always present, which is what makes a resource's name
unambiguous even if a host and a service share a name.
- **Within a segment, normalize to lowercase** — spaces and stray `_``-`; strip
other non-`[a-z0-9-]`. A host named `Web 01` and a site `Main Office` (resource
slugs `host_web-01` and `site_main-office`) yield groups `site_main-office_host_web-01_*`.
- **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
# resource.name is the resource's name slug (kind prefix stripped); the kind
# is its own segment. A host `host_theta-env` has name `theta-env`, kind `host`.
specific = f"{site}_{resource.kind}_{resource.name}_{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` (site resource slug `site_main-office`) imports:
```
(&(objectClass=groupOfNames)(|(cn=site_main-office_host_web01_access)
(cn=site_main-office_host_web01_admin)
(cn=site_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."
+175
View File
@@ -0,0 +1,175 @@
---
title: Canonical demo fixtures
---
# Canonical demo fixtures
The exact users, groups, and hosts that should exist on a stack used for
screenshots or demos, so every future pass seeds the *same* data and a
screenshot diff only shows what actually changed in the UI — not incidental
differences in who/what happened to exist that day.
Persona: a single admin/power-user running theta42 across a **big homelab and
a small business** — mix of self-hosted infra (Proxmox, Pi-hole, Plex) and
office-y apps (invoicing, helpdesk, wiki) with real department structure.
Domain: `laptop-dev.vm42.us` (real public DNS pointing at this machine — see
"Domain" below). Update this doc if the domain ever changes again.
## Users
| uid | Name | Department | Password | Notes |
|---|---|---|---|---|
| `schen` | Sarah Chen | Engineering | `DemoPass123!` | DevOps lead |
| `dkim` | David Kim | Engineering | `DemoPass123!` | Backend developer |
| `ppatel` | Priya Patel | Engineering | `DemoPass123!` | Frontend developer |
| `mjohnson` | Marcus Johnson | Finance | `DemoPass123!` | Finance manager |
| `lnguyen` | Linda Nguyen | Finance | `DemoPass123!` | Bookkeeper |
| `erodriguez` | Emily Rodriguez | Support | `DemoPass123!` | Support lead |
| `tbaker` | Tom Baker | Support | `DemoPass123!` | Support tech |
| `jwilson` | James Wilson | Management | `DemoPass123!` | Owner |
| `svc-monitoring` | — | service account | `ServiceAcct!2024` | Grafana/Prometheus scraping |
| `svc-backup` | — | service account | `ServiceAcct!2024` | Backup automation |
uidNumbers 50005009 in that order. Mail is `<first>.<last>@laptop-dev.vm42.us`
(service accounts use their uid, e.g. `monitoring@laptop-dev.vm42.us`).
## Groups
`groupOfNames`, owned by `cn=admin,...`, member of the department's users:
- `engineering` — schen, dkim, ppatel
- `finance` — mjohnson, lnguyen
- `support` — erodriguez, tbaker
- `management` — jwilson
- `app_sso_service_account` (built-in) — svc-monitoring, svc-backup
## Seeding users + groups
```sh
cd theta-env
docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh
docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh
```
Idempotent — re-running skips anything that already exists. If you add a
fixture below, add it to `bootstrap/seed-demo-users.sh` too and keep the two
in sync.
## Proxy hosts
All under `*.laptop-dev.vm42.us`. `setup.sh` itself creates the first two
(sso, proxy) — everything else below is added by hand through Hosts → Add
host (Proxy UI, currently no seed script — see note at the bottom).
| Host | Target | Auth | Notes |
|---|---|---|---|
| `sso` | `sso-manager:3001` | — | created by `setup.sh` |
| `proxy` | `127.0.0.1:3000` | — | created by `setup.sh` |
| `jump` | `jump-host:3002` | — | created by `setup.sh` |
| `proxmox` | `10.0.10.5:8006` (HTTPS) | Basic — realm "Proxmox VE", users `dkim`, `schen` | |
| `pbs` | `10.0.10.6:8007` (HTTPS) | Basic — realm "Proxmox Backup Server", user `dkim` | |
| `grafana` | `10.0.10.12:3000` + LB target `10.0.10.13:3000` | SSO — group `engineering` | load-balancing example |
| `nextcloud` | `10.0.10.20:80` | SSO — any authenticated user | empty allow-lists |
| `ha` | `10.0.10.30:8123` | Basic — realm "Home Assistant", user `jwilson` | |
| `jenkins` | `10.0.10.40:8080` | SSO — group `engineering` | |
| `gitea` | `10.0.10.41:3000` | Off (public) | has its own login |
| `plex` | `10.0.10.50:32400` | Off (public) | has its own login |
| `nas` | `10.0.10.60:5001` (HTTPS) | Basic — realm "Synology NAS", user `jwilson` | |
| `pihole` | `10.0.10.61:80` | Basic — realm "Pi-hole Admin", user `dkim` | |
| `wiki` | `10.0.10.70:3000` | SSO — any authenticated user | |
| `invoices` | `10.0.10.80:8000` | SSO — group `finance` | small-business flavor |
| `helpdesk` | `10.0.10.81:3000` | SSO — group `support` | small-business flavor |
Basic-auth passwords used: `dkim:HomeLab!2024`, `schen:Engineering!24`,
`jwilson:HomeOwner!24`.
## Domain
`CFG_DOMAIN=laptop-dev.vm42.us` in `setup.env`, real public DNS (CNAME
through `718it.biz`) that resolves back to this machine. `CFG_LDAPS_HOST`
is pinned to the LAN IP of the interface holding the default route
(`ip route get 1.1.1.1`), not just any active interface — this machine had
two (wifi + USB ethernet) and only one was actually externally reachable
through the existing port-forward/prod-proxy setup.
A production reverse proxy in front of this host handles TLS/ACME for
`*.718it.biz`-family domains (to avoid hitting Let's Encrypt's rate limits
re-provisioning a cert every time this dev stack rebuilds) — if a fresh
rebuild's Host records don't resolve correctly from the public domain right
after `setup.sh`, that's the layer to check, not this stack's own nginx/lua
routing. `curl -sk -D - https://sso.laptop-dev.vm42.us/` from the host
machine is the fastest way to confirm whether the issue is server-side.
## Known-good login shortcuts
Skip SSO's self-signed-cert dance entirely for admin/screenshot work — every
app ships a local anti-lockout admin account for exactly this:
```sh
# SSO Manager admin (bootstrap account, uid "admin")
node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)"
# Proxy — username proxyadmin2
node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)"
# Jump-host — username jumpadmin
node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)"
```
Ports (from `setup.env` — check it, these are operator-configurable):
SSO `3001`, Proxy management UI `3010` (`MGMT_PORT`), Jump-host `3002`.
A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS,
enter a DOB) before the rest of the UI is usable — expect that on a stack
that was just rebuilt from scratch.
## Jump-host access (SSO Directory resource)
Jump-host's dashboard ("Hosts you can reach") is **not** driven by Proxy's
Host records — it resolves access via the SSO Manager's own Directory
(`kind: host` resources), filtered by the logged-in user's LDAP group
membership. This is a completely separate system from Proxy's HTTP-routing
hosts above; a Proxy host existing does not make it SSH-reachable through
jump-host.
For a `dkim`-can-reach-something screenshot, one Directory host resource was
added:
- **Directory → Add Resource**: name `proxmox-node`, kind `Host`, IP
`10.0.10.5`, parent resource `local (site_local)`.
- **Associated LDAP Groups → `site_local_host_proxmox-node_access`
Members → Add member → `dkim`** (added the individual user directly, not
the `engineering` group — the resource's own auto-generated `_access`
group's member picker only offers individual users).
To reproduce: repeat those two steps for `proxmox-node` if it's missing, or
add more Directory host resources the same way for a richer "Hosts you can
reach" list.
**To screenshot as a real fixture user** (not the `jumpadmin` local
anti-lockout admin, whose "My hosts" list is always non-empty by virtue of
infra ownership, not a real access grant): log out, click "Log in with
Jump" on the login page, and sign in as `dkim` / `DemoPass123!` through the
real SSO flow. This exercises the actual OIDC redirect through
`sso.laptop-dev.vm42.us` — by this point in the session it worked cleanly in
the browser; if it doesn't (stale cookies/redirect loop from an earlier bad
state), see `docs/screenshots.md` §2 for the fallback.
## What's not yet automated
Proxy hosts are still added by hand (no `seed-demo-hosts.sh` equivalent) —
the Proxy UI has no simple LDIF-style bulk-import path the way LDAP does, and
scripting it means either driving the browser or reverse-engineering the
session-cookie login flow for curl. If this list changes often enough to be
annoying, that's the next thing worth building — a small node script run via
`docker compose exec proxy node ...` calling the `Host` model directly,
mirroring how `setup.sh`'s own step 7 registers the sso/proxy hosts.
## Screenshot workflow
See `docs/screenshots.md` for the full screenshot-capture workflow
(save-to-disk, where each doc image lives, the app.modal.js browser-cache
gotcha). Once fixtures match this doc, only re-screenshot pages whose UI
actually changed since the last pass — the data itself shouldn't be the
reason a screenshot looks different.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 332 KiB

+4
View File
@@ -50,6 +50,10 @@ snapshots state before every rebuild.
- **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
LDAP-native apps authenticate against the same directory.
- **Hierarchical groups & permissions** — every adopted host and app gets its own
`admin`/`access`/`capability` groups, generated from the Directory; they double
as real POSIX groups for sudo/SSH. See
[Group & Permission Model](GROUPS.html).
- **ldap-client** — enroll Linux hosts into the directory (PAM/SSSD login,
sudo, SSH keys); the host inventory shows up in the SSO UI and drives
jump-host routing.
+127
View File
@@ -0,0 +1,127 @@
---
title: Updating gitpages screenshots
---
# Updating gitpages screenshots
How to refresh the docs/images/*.png screenshots across sso-manager-node,
proxy, jump-host, and theta-env's own docs. This comes up periodically as the
UI changes — this doc + `docs/fixtures.md` + `bootstrap/seed-demo-users.sh`
exist so it doesn't have to be re-figured-out from scratch each time. Once
fixtures match `docs/fixtures.md`, you only need to re-screenshot pages whose
UI actually changed since the last pass.
## 1. Seed realistic demo data
Screenshots should show a believable homelab/small-business setup, not empty
tables or `test`/`vaulttest` accounts, and the **same** cast every time — see
`docs/fixtures.md` for the canonical list (exact users, groups, hosts,
passwords) and keep it in sync with what's actually seeded. Seed users +
groups with:
```sh
docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh
docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh
```
Idempotent — safe to re-run, existing entries are skipped. Proxy hosts have
no equivalent script yet — add them by hand through the Proxy UI (Hosts →
Add host), following `docs/fixtures.md`'s host table exactly (same hostnames,
targets, auth config every time).
## 2. Logging in without fighting SSO/TLS
The SSO's own domain goes through real DNS + a production reverse proxy in
front of this dev stack (see `docs/fixtures.md` → Domain) — logging in via
"Log in with SSO" from Proxy/Jump-host round-trips through that whole path
and can hit stale-cookie/redirect-loop artifacts in an automation browser
profile that a real browser wouldn't. Don't fight this — every app ships a
local anti-lockout admin for exactly this situation. Read the password
straight out of the mounted secrets:
```sh
# SSO Manager admin (bootstrap account, uid "admin")
node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)"
# Proxy — username proxyadmin2
node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)"
# Jump-host — username jumpadmin
node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)"
```
Log in at `http://localhost:<port>/login` for each app — plain HTTP on the
mapped port, no cert/cookie issues at all. Ports come from `setup.env`
(operator-configurable) — check it rather than assuming defaults; e.g. this
deployment maps the Proxy UI to `3010` (`MGMT_PORT`), not the usual `3000`.
**Don't touch the login form if it autofills a real saved username/password**
(Chrome profile password manager) — clear the fields and type the local admin
credentials above instead. Never submit a real saved credential on the
user's behalf.
A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS,
enter a DOB) before the rest of the UI is usable — expect that right after a
from-scratch rebuild.
## 3. Known gotcha: stale `app.modal.js` in the browser cache
If "Add host" (or any `app.modal`-based modal) opens with tabs/fields but no
Save/Cancel footer, check the console for
`TypeError: app.modal.on is not a function`. That means the browser has an
HTTP-cached copy of `@simpleworkjs/frontend/lib/app.modal.js` from before a
method (`on`, `showTab`, etc.) was added — `curl`-ing the same URL returns the
current file, so it's a caching artifact, not a real app bug. Fix it in-page
without a full hard-reload cycle:
```js
// via the browser automation JS tool, in the page context
const res = await fetch('/static-modules/@simpleworkjs/frontend/lib/app.modal.js', {cache: 'reload'});
await res.text(); // {cache:'reload'} both bypasses AND refreshes the cache entry
```
Then reload the page normally — the fresh file sticks for the rest of the
session.
## 4. Capture screenshots
Use `save_to_disk: true` on the browser screenshot action so files land on
disk instead of just being viewed inline. One screenshot per doc image:
| File | Page |
|---|---|
| `sso-manager-node/docs/images/dashboard.png` | SSO Catalog (`/`) |
| `sso-manager-node/docs/images/users.png` | SSO Users → People (`/users`) |
| `sso-manager-node/docs/images/directory.png` | SSO Directory (`/directory`) |
| `sso-manager-node/docs/images/groups.png` | A user's profile → "My Groups" tab |
| `sso-manager-node/docs/images/oauth-clients.png` | Directory → an `oauth` resource → Edit → Details tab |
| `proxy/docs/images/hosts.png` | Proxy Hosts list (`/hosts`) |
| `proxy/docs/images/host-auth-basic.png` | Edit a basic-auth host → Authentication tab |
| `proxy/docs/images/host-auth-sso.png` | Edit an SSO-auth host → Authentication tab |
| `proxy/docs/images/load-balancing.png` | Edit a host with "Additional Targets" filled in → General tab |
| `jump-host/docs/images/login.png` | Jump-host login page |
| `jump-host/docs/images/dashboard.png` | Jump-host dashboard, logged in as a real fixture user (e.g. `dkim` via SSO) with an actual access grant — not the `jumpadmin` local admin, whose host list isn't representative. See `docs/fixtures.md` → Jump-host access. |
| `jump-host/docs/images/sessions.png` | Jump-host active sessions |
| `jump-host/docs/images/audit.png` | Jump-host audit log |
| `theta-env/docs/images/sso-dashboard.png` | same as SSO Catalog above |
| `theta-env/docs/images/proxy-hosts.png` | same as Proxy Hosts above |
| `theta-env/docs/images/jump-dashboard.png` | same as Jump-host dashboard above |
## 5. Where to save them
Only update the **top-level active clones**
`/home/william/dev/theta42/{sso-manager-node,proxy,jump-host,theta-env}` (all
on `master`). The copies nested under `theta-env/sso-manager-node`,
`theta-env/proxy`, `theta-env/jump-host` are git submodules pinned to a
release tag (`HEAD detached at vX.Y.Z`) — those update automatically the next
time theta-env's release/tag-bump workflow rolls the submodule pointer
forward, not by hand-editing the pinned checkout.
```sh
convert screenshot.jpg /home/william/dev/theta42/<repo>/docs/images/<name>.png
```
(`convert` from ImageMagick — the browser tool saves JPEGs, but the repos
track PNGs.)
Commit each repo separately, same as any other change to that component.
+51 -10
View File
@@ -60,23 +60,49 @@ never passed to a service container.
| Policy | Capabilities | Held by |
|---|---|---|
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; `update` on `auth/token/create/sso-broker`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`, `secret/agent/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
| `user-<uid>` | read/write `secret/users/<uid>/*` | per-user tokens (minted lazily by the broker) |
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
**Token role `sso-broker`** — `allowed_policies=sso-admin`,
`allowed_policies_glob=user-*,app-*`, orphan, renewable, `token_period=24h`.
The SSO mints per-user, per-admin, and per-app tokens *through* this role at
runtime, so it never needs the root token to issue scoped access.
**Token roles** — three, all orphan + renewable:
The three per-app tokens (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`) are minted orphan + renewable and stored in `./.env` by
`setup.sh`. They use OpenBao's default service-token TTL; if one expires,
re-run `./setup.sh` and the `ensure_token` helper re-mints it (the old one
expires on its own). Automated renewal is a planned follow-up, not yet built.
- `sso-broker``allowed_policies=sso-admin`, `allowed_policies_glob=user-*,app-*`,
`token_period=24h`. The SSO mints per-user and per-admin tokens *through*
this role at runtime, so it never needs the root token to issue scoped
access. The 24h period is fine here because the broker re-mints these from
its Redis cache transparently.
- `sso-app``allowed_policies_glob=app-*`, `token_period=768h`. External-app
tokens minted from the vault UI's Apps tab go through this role: they are
long-lived credentials, so they get a monthly period instead of a daily one.
- `theta-svc``allowed_policies=sso-broker,proxy,jump-host`,
`token_period=768h`. The services' own tokens (below).
### Token lifecycle — nothing expires by surprise
Periodic tokens never hit a max TTL, but they die if nothing renews them
inside a period window. Renewal is automated at every layer:
- **Service tokens** (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`, minted via `theta-svc`, stored in `./.env`): the
`bao-renewer` sidecar (docker-compose) renews all three every 12 hours, and
every `setup.sh` re-run renews them too. A valid-but-non-periodic token from
an older install is detected, revoked, and re-minted as periodic on the next
`setup.sh` run.
- **External-app tokens** (minted in the SSO vault UI): the SSO stores each
token's **accessor** (which can renew/revoke but not authenticate) and
renews it every 6 hours and at boot — a downstream app's credential stays
valid as long as the SSO is running, with no renewal code in the downstream
app. Re-minting an app's token revokes the previous one via its accessor, so
exactly one credential per app is ever live.
- **Per-user / admin tokens**: 24h TTL by design; the broker re-mints them
transparently, so there is nothing to renew.
Worst case (the whole stack was down for >32 days): re-run `./setup.sh` — it
re-mints anything that lapsed; external-app tokens are re-minted from the
Apps tab (the app's policy and stored secrets are kept).
## Seeding
@@ -150,6 +176,21 @@ const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-s
await baoConf.set('apps/my-service/conf', { db_password: '...' });
```
## The theta-agent signing key
The SSO signs high-risk theta-agent commands (`reboot`, `configure_ldap`,
`arbitrary_bash`, …) with an Ed25519 key stored at
`secret/agent/signing-key`. Agents pin the matching public key in their
`agent.yml`, so the key **must** be stable: it used to be generated in memory at
process start, which meant it changed on every restart and no agent could
meaningfully verify anything.
If the SSO cannot read or write that path it refuses to send high-risk commands
rather than signing with a key no agent has seen — so an upgraded stack that has
not re-run `./setup.sh` (and therefore lacks `secret/agent/*` in the
`sso-broker` policy) will report `signingAvailable: false` on
`GET /api/agent/nodes` and reject those commands with a clear error.
## Plugin secrets
The SSO Manager's plugin system (configurable plugin instances you create,
+1 -1
Submodule proxy updated: 8107755307...ac5bce6a86
+175 -14
View File
@@ -292,6 +292,21 @@ dn_from_domain() {
echo "dc=$1" | sed 's/\./,dc=/g'
}
# Read a value from the (operator-owned) ./config/sso-secrets.js -- the source of
# truth on re-runs, where the CFG_* first-run shell vars are not (re)derived
# (ensure_config returns early once sso-secrets.js exists). Reads `stack.<key>`.
# Prints empty on any failure. Usage: sso_secrets_get ldapBaseDn
sso_secrets_get() {
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c.stack&&c.stack[k]!=null?c.stack[k]:"")' \
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
}
# Read a top-level (non-stack) secret from sso-secrets.js, e.g. serviceAccountPass.
sso_secrets_get_top() {
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c[k]!=null?c[k]:"")' \
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
}
# Write ./config/sso-secrets.js from the CFG_* shell vars.
write_sso_secrets() {
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
@@ -383,6 +398,12 @@ module.exports = {
groupsClaim: 'groups',
usernameClaim: 'preferred_username',
},
// Read-only SSO management API access, used to list directory groups for the
// per-host SSO allow-list autocomplete. apiToken is minted by the bootstrap.
sso: {
url: 'http://sso-manager:3001',
apiToken: '',
},
ldap: {
url: 'ldaps://sso-manager:636',
bindDN: $(js_str "cn=ldapclient,ou=people,${dn}"),
@@ -810,17 +831,34 @@ ensure_policy() {
}
# Mint an orphan, renewable token for `policy` and persist it to .env as `key`,
# OR reuse the token already in .env if it is still valid (re-mint on expiry).
# Mint a PERIODIC service token (theta-svc role: orphan, renewable, 768h
# period) for `policy` and persist it to .env as `key`. Periodic tokens have no
# max-TTL death date — each renewal resets the clock — unlike the plain orphan
# tokens minted before this (creation_ttl 768h, dead ~32 days after mint no
# matter what). The bao-renewer sidecar renews them every 12h while the stack
# runs, and every setup.sh re-run renews here too. A valid-but-non-periodic
# token from an older setup.sh is revoked and re-minted as periodic.
ensure_token() {
local key="$1" policy="$2" existing tok
local key="$1" policy="$2" existing tok lookup
existing="$(env_get "$key")"
if [[ -n "$existing" ]] && docker exec -e BAO_TOKEN="$existing" openbao bao token lookup >/dev/null 2>&1; then
info " ${key} already minted + valid — keeping."
return 0
if [[ -n "$existing" ]]; then
lookup="$(docker exec -e BAO_TOKEN="$existing" openbao bao token lookup -format=json 2>/dev/null || true)"
if [[ -n "$lookup" ]]; then
# Periodic = minted through the theta-svc role. (OpenBao token lookup
# does not expose a `period` field — the role is the reliable marker;
# renewal behavior confirms the 768h period resets past the original
# creation TTL.)
if echo "$lookup" | grep -q '"role": *"theta-svc"'; then
info " ${key} already minted + periodic (theta-svc) — renewing to reset its clock."
docker exec -e BAO_TOKEN="$existing" openbao bao token renew >/dev/null 2>&1 || true
return 0
fi
info " ${key} is valid but NOT periodic (pre-theta-svc mint; dies at its max TTL) — revoking + re-minting."
bao_run token revoke "$existing" >/dev/null 2>&1 || true
fi
fi
info " minting ${key} (policy=${policy})..."
tok="$(bao_run token create -policy="$policy" -orphan=true -field=token)" \
info " minting ${key} (policy=${policy}, role=theta-svc, periodic 768h)..."
tok="$(bao_run token create -role=theta-svc -policy="$policy" -field=token)" \
|| die "failed to mint ${key} (policy=${policy})"
env_upsert "$key" "$tok"
}
@@ -857,7 +895,16 @@ path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete"
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
# The Ed25519 key the SSO signs high-risk theta-agent commands with. It must
# persist across restarts: agents pin the matching public key in agent.yml, so
# a key that changes on every boot makes signature verification meaningless.
path "secret/data/agent/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/agent/*" { capabilities = ["list", "read", "delete"] }
path "auth/token/create/sso-broker" { capabilities = ["update"] }
path "auth/token/create/sso-app" { capabilities = ["update"] }
path "auth/token/renew-accessor" { capabilities = ["update"] }
path "auth/token/revoke-accessor" { capabilities = ["update"] }
path "auth/token/lookup-accessor" { capabilities = ["update"] }
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
@@ -896,15 +943,48 @@ else
info " token role sso-broker already exists — keeping."
fi
# sso-app token role: external-app tokens minted from the sso vault UI. Periodic
# 768h (NOT the broker's 24h) — an app token is a long-lived credential; with a
# 24h period any downstream app that didn't renew daily silently died. A 768h
# period keeps it alive as long as the app renews (or is re-minted) at least
# monthly: `bao token renew-self` / POST /v1/auth/token/renew-self.
info "Configuring sso-app token role..."
if ! bao_run read auth/token/roles/sso-app >/dev/null 2>&1; then
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-app - <<'JSON' >/dev/null
{"allowed_policies_glob":["app-*"],"orphan":true,"renewable":true,"token_period":"768h"}
JSON
else
info " token role sso-app already exists — keeping."
fi
# theta-svc token role: the services' own tokens (SSO/PROXY/JUMP_VAULT_TOKEN).
# Periodic 768h so they can be renewed forever (the bao-renewer sidecar renews
# every 12h; each setup.sh re-run renews too). allowed_policies is exact-match:
# exactly the three service policies, nothing else.
info "Configuring theta-svc token role..."
if ! bao_run read auth/token/roles/theta-svc >/dev/null 2>&1; then
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/theta-svc - <<'JSON' >/dev/null
{"allowed_policies":["sso-broker","proxy","jump-host"],"orphan":true,"renewable":true,"token_period":"768h"}
JSON
else
info " token role theta-svc already exists — keeping."
fi
info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..."
ensure_token SSO_VAULT_TOKEN sso-broker
ensure_token PROXY_VAULT_TOKEN proxy
ensure_token JUMP_VAULT_TOKEN jump-host
info "OpenBao secrets configured:"
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
info " token role: sso-broker (mints user-*/app-*/sso-admin tokens, 24h period)"
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env"
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
info " token roles: sso-broker (user-*/app-*/sso-admin, 24h period), sso-app (app-*, 768h period), theta-svc (service tokens, 768h period)"
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env (periodic; renewed by bao-renewer)"
# bao-renewer: renews the three periodic service tokens every 12h so they never
# hit their period boundary while the stack is running. Recreated (not just
# started) so it always picks up freshly re-minted tokens from .env.
info "Starting bao-renewer (service-token renewal sidecar)..."
"${COMPOSE[@]}" up -d --force-recreate bao-renewer
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
@@ -1000,7 +1080,13 @@ STACK_HOST_MAC=""
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
# The compose project name the stack runs under (defaults to the directory
# name). The bootstrap hands it to the Docker discovery plugin so the stack's
# own containers are recognised as ours rather than discovered as strangers.
STACK_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-' | sed 's/-*$//')}"
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e COMPOSE_PROJECT_NAME="$STACK_COMPOSE_PROJECT" \
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
-e STACK_HOST_IP="$STACK_HOST_IP" \
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
@@ -1015,6 +1101,9 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
CLIENT_ID=$(getval CLIENT_ID)
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
# The one credential the local theta-agent needs; it exchanges this for its own
# token + the SSO public key on first connect (see 7c below).
AGENT_JOIN_KEY=$(getval AGENT_JOIN_KEY)
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
if [[ "$ALREADY_CONFIGURED" == "1" ]]; then
@@ -1150,15 +1239,43 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
sudo mkdir -p /etc/theta42
if [[ ! -f /etc/theta42/agent.yml ]]; then
sudo cp agent.yml.example /etc/theta42/agent.yml
AGENT_TOKEN="$(rand_hex 16)"
sudo sed -i "s/REPLACE_WITH_AGENT_TOKEN/$AGENT_TOKEN/" /etc/theta42/agent.yml
# Write the JOIN KEY, not a locally-invented token. The SSO
# only accepts credentials it issued, so the random token
# this used to generate could never authenticate -- the
# agent looped on "close 4001: Unauthorized" forever. The
# agent swaps this key for its own token (and the public key
# it must pin) on first connect and rewrites this file.
if [[ -n "$AGENT_JOIN_KEY" ]]; then
# Only the join key is written. The agent exchanges it
# for its own token + the SSO public key on first
# connect and rewrites this file itself.
#
# This used to sed a locally generated random value into
# auth_token. The SSO only accepts credentials it
# issued, so that token could never authenticate and the
# agent looped on "close 4001: Unauthorized" forever.
if sudo grep -q '^join_key:' /etc/theta42/agent.yml; then
sudo sed -i "s|^join_key:.*|join_key: \"${AGENT_JOIN_KEY}\"|" /etc/theta42/agent.yml
else
echo "join_key: \"${AGENT_JOIN_KEY}\"" | sudo tee -a /etc/theta42/agent.yml >/dev/null
fi
# Older agent.yml.example shipped REPLACE_WITH_* placeholders;
# blank them so they are not mistaken for real credentials.
sudo sed -i "s|REPLACE_WITH_ISSUED_AGENT_TOKEN||; s|REPLACE_WITH_AGENT_TOKEN||; s|REPLACE_WITH_SSO_PUBLIC_KEY||" /etc/theta42/agent.yml
else
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
fi
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
else
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
fi
sudo chmod 600 /etc/theta42/agent.yml
fi
# Stop a running agent before overwriting its binary (cp into a
# running executable fails with "Text file busy" on a re-install).
sudo systemctl stop theta-agent.service 2>/dev/null || true
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
sudo chmod +x /usr/local/bin/theta-agent
@@ -1200,6 +1317,45 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]
if [[ "$CFG_THETA_AGENT_LDAP_AUTH" == "1" ]]; then
info " Configuring LDAP authentication for this host..."
# ldap-client/index.sh refuses to run without ./ldap.vars, which is
# gitignored and never shipped in the checkout (it holds a real bind
# password). On the agent-enrollment path we generate it from the stack's
# own config so the host can actually enroll; an operator-provided
# ldap.vars (cp ldap.vars.template ldap.vars + edit) is always kept.
if [[ ! -f ldap-client/ldap.vars ]]; then
info " Generating ldap-client/ldap.vars from the stack config..."
# CFG_* first-run vars may be unset on a re-run (ensure_config returns
# early once sso-secrets.js exists), so fall back to reading the real
# values from the operator-owned sso-secrets.js. All `:-` guarded so a
# missing value degrades to an empty ldap.vars field, not a set -u abort.
ldap_base_dn="${CFG_BASE_DN:-$(sso_secrets_get ldapBaseDn)}"
ldap_site="${CFG_SITE_NAME:-$(sso_secrets_get siteName)}"
ldap_bind_pass="${CFG_SVC_PASS:-$(sso_secrets_get_top serviceAccountPass)}"
sso_host="${CFG_SSO_HOST:-$(sso_secrets_get ssoHost)}"
# The LDAP server is co-located with the stack on THIS host, so the
# host must reach it over the loopback / a local address -- NEVER the
# public domain (sso.<domain>), which cannot route back to the 389/636
# ports through NAT. localhost is fine because the generated sssd.conf
# sets ldap_tls_reqcert=never (hostname verification is off). An
# operator may override with CFG_LDAPS_HOST (an internal hostname/IP).
ldaps_host="${CFG_LDAPS_HOST:-localhost}"
cat > ldap-client/ldap.vars <<LDAPVARS
export ldap_host="${ldaps_host}"
export ldap_base_dn="${ldap_base_dn}"
export ldap_bind_dn="cn=ldapclient,ou=people,${ldap_base_dn}"
export ldap_bind_password="${ldap_bind_pass}"
export sso_url="https://${sso_host}"
export sso_token=""
export ldap_location="${ldap_site:-local}"
# Groups that grant SSH/access on this host (docs/GROUPS.md §8): the site's
# all-hosts aggregate, this host's own access group, and god_admin.
ldap_access_groups=( "site_\${ldap_location}_hosts_access" "site_\${ldap_location}_host_\$(hostname)_access" "god_admin" )
LDAPVARS
else
info " ldap-client/ldap.vars exists -- keeping it"
fi
(
cd ldap-client || exit 0
if [[ -x "index.sh" ]]; then
@@ -1214,9 +1370,14 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]
info " Configuring theta-agent with full host control capabilities..."
if [[ -f /etc/theta42/agent.yml ]]; then
sudo sed -i 's/arbitrary_bash: false/arbitrary_bash: true/' /etc/theta42/agent.yml
sudo sed -i 's/service_control: .*/service_control: true/' /etc/theta42/agent.yml
# service_control is a []string allowlist (NOT a bool) — setting it to
# `true` makes the agent fail YAML decode and crash-loop. There is no
# wildcard; leave the operator's list (or the [] default = deny all)
# alone and document how to enable specific services.
# sudo sed -i 's/service_control: .*/service_control: true/' ...
sudo sed -i 's/reboot: false/reboot: true/' /etc/theta42/agent.yml
sudo sed -i 's/configure_ldap: false/configure_ldap: true/' /etc/theta42/agent.yml
info " (service_control left as its allowlist; set e.g. service_control: [\"nginx\"] in /etc/theta42/agent.yml to permit managing specific services)"
info " theta-agent full control enabled. Restarting service..."
sudo systemctl restart theta-agent.service
else