Compare commits

..

31 Commits

Author SHA1 Message Date
wmantly 51b42b3d8a fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 27s
Pull Request Tests / Test Summary (push) Failing after 3s
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 22:56:19 -04:00
wmantly 8db00f0ed6 fix: drop legacy app_super_admin -- SUPER_ADMIN_GROUP is now god_admin (v1.26.1) (#167)
god_admin now exists at boot (seeded by docker-entrypoint), so the canonical
cross-resource super group nested into every resource's _admin group is god_admin,
not the legacy app_super_admin. docker-entrypoint no longer seeds or nests
app_super_admin (god_admin nests into the app_sso_* groups directly). isSuperAdmin
still recognizes a pre-existing app_super_admin as a migration alias until rebuild.
2026-08-04 19:31:52 -04:00
wmantly 8a9de94d24 release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166)
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)

- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0

* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)

The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
2026-08-04 19:07:51 -04:00
wmantly 512a28d1f5 Merge pull request #165 from theta42/fix/version-1.25.0
chore: bump package.json to 1.25.0
2026-08-04 16:47:15 -04:00
wmantly 398b64f5e3 chore: bump package.json + lockfile to 1.25.0
Keep the release version in sync with the v1.25.0 tag (the changelog was bumped
but package.json was left at 1.23.0, which would trigger a false update-check
banner).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:43:08 -04:00
wmantly 8d6c7dffd0 Merge pull request #164 from theta42/release/v1.25.0
feat: hierarchical group & permission model (v1.25.0)
2026-08-04 16:42:20 -04:00
wmantly 50d093f28b Merge branch 'master' into release/v1.25.0 2026-08-04 16:37:57 -04:00
wmantly f00d311029 fix: keep SUPER_ADMIN_GROUP as app_super_admin so resource auto-provisioning nesting works
api_directory_admin nests permission.SUPER_ADMIN_GROUP into every new resource's
_admin group. Changing it to the not-yet-existing 'god_admin' made that nesting
no-op, leaving the creator as the sole member (so the access_request test's
beforeAll could not remove the last member of a groupOfNames). Revert it to
'app_super_admin' and recognize 'god_admin' separately in isSuperAdmin + isAdmin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:31:00 -04:00
wmantly 88b2255d5a Merge pull request #160 from theta42/dependabot/npm_and_yarn/nodejs/undici-6.28.0
chore(deps): bump undici from 6.27.0 to 6.28.0 in /nodejs
2026-08-04 16:24:38 -04:00
wmantly b0819e81e6 Merge branch 'master' into dependabot/npm_and_yarn/nodejs/undici-6.28.0 2026-08-04 16:13:52 -04:00
wmantly d41915f955 Merge pull request #158 from theta42/dependabot/npm_and_yarn/nodejs/ip-address-10.4.0
chore(deps): bump ip-address from 10.2.0 to 10.4.0 in /nodejs
2026-08-04 16:12:57 -04:00
wmantly be8ccf66e9 Merge branch 'master' into dependabot/npm_and_yarn/nodejs/undici-6.28.0 2026-08-04 16:08:38 -04:00
wmantly 58597ac8fd Merge branch 'master' into dependabot/npm_and_yarn/nodejs/ip-address-10.4.0 2026-08-04 16:08:36 -04:00
wmantly d02ba32925 Merge pull request #156 from theta42/dependabot/npm_and_yarn/nodejs/multi-e855e31deb
chore(deps): bump brace-expansion in /nodejs
2026-08-04 16:07:47 -04:00
wmantly 6d9c2f05ba feat: hierarchical group & permission model (v1.25.0)
- Add utils/groups.js: the group schema + inheritance resolver (god_admin,
  {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, per-resource
  admin/access/<capability>, meta everyone/{site}_everyone). admin implies
  access; capabilities explicit; hosts/apps orthogonal; cross-site isolated.
- permission.js: recognize god_admin (legacy app_super_admin aliased) and add
  onResource/requireResource for resource-level checks + everyone meta grants.
- user.js isAdmin: recognize god_admin + site-scoped super/app-admin groups.
- Remove the standalone Groups page (nav + route + view); groups are managed on
  adopted Directory resources. Add a /docs/groups help link in the Directory
  toolbar (GROUPS.md copied into the SSO docs).
- tests/groups.test.js: full resolver coverage (15 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:03:36 -04:00
wmantly bbcc235b68 feat: Directory agent status + plugin modal rework, Vault restyle, navbar (v1.24.0)
- Merge theta-agent into Directory: remove the Agents page; add green/yellow/red
  status dots to host rows and a Metrics tab (telemetry + discovery) to the
  resource modal, joined to hosts by hostname, live via socket.io + 30s refresh.
- Discovery Plugins New-plugin modal: slug derived from name (field removed),
  cron dropdown (hourly/daily/weekly/custom), configSchema-driven settings
  (Proxmox url/tokenId/tokenSecret) sent as a populated config.
- Directory resource slug now read-only + derived from name.
- Vault page restyled to match the site.
- Navbar: username no longer underlined; only the active link is bold+underlined.
- docs/agents.md: document the Directory status/metrics + NAT troubleshooting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:25 -04:00
dependabot[bot] 93c47751db chore(deps): bump brace-expansion in /nodejs
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.1.2 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

Updates `brace-expansion` from 1.1.16 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

Updates `brace-expansion` from 5.0.7 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:47 +00:00
dependabot[bot] 9a438bd30e chore(deps): bump undici from 6.27.0 to 6.28.0 in /nodejs
Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:41 +00:00
dependabot[bot] c618e75a22 chore(deps): bump ip-address from 10.2.0 to 10.4.0 in /nodejs
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:35 +00:00
wmantly 69434d06ec Merge pull request #163 from theta42/release/v1.23.0-vault-proxy-fix
fix vault 403 for real (v1.23.0)
2026-08-04 00:19:16 -04:00
wmantly dd24257640 fix vault 403 for real (v1.23.0)
- /api/vault proxy now injects X-Vault-Token: the proxy declared its request
  hook with http-proxy-middleware v3 syntax (on: { proxyReq }), which the
  installed HPM v2 silently ignores — so every vault call reached OpenBao
  unauthenticated (the recurring 403). Rewritten as v2 onProxyReq.
- Header injection ordered before fixRequestBody (the body write flushes
  headers; setting X-Vault-Token after it failed on every POST/PUT).
- initORM add-only schema heal: sequelize.sync() never ALTERs, so newer columns
  (PluginInstance.lastLog) are now added via describeTable + addColumn.
- Long-lived external-app tokens via sso-app role (768h periodic); VaultAppToken
  stores each app token's accessor and renews it at boot + every 6h; re-minting
  revokes the previous token via its accessor.
- Wire-level tests for the vault proxy + app-token accessor lifecycle.
- package.json + lockfile bumped to 1.23.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:11:51 -04:00
wmantly b06aeca363 Merge pull request #162 from theta42/feat/agents-page-v1.22.0
feat: Agents page + secure /api/agent REST (v1.22.0)
2026-08-03 23:14:49 -04:00
wmantly ccf3122668 feat: Agents page + secure /api/agent REST (v1.22.0)
- New admin Agents page (nav + /agents route + views/agents.ejs): live list of
  connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry and online
  status, updated live over socket.io ('agent.telemetry'/'agent.discovery').
- Auth + admin-gate the /api/agent REST router (it was mounted without
  middleware.auth — anyone could list nodes / send commands). The agent
  WebSocket (/api/agent/ws) is unaffected (handled by the raw wss upgrade with
  its own token auth).
- package.json + lockfile bumped to 1.22.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:10:16 -04:00
wmantly 5aad6c13bf Merge pull request #161 from theta42/fix/vault-403-shared-secrets
fix vault 403 + shared secrets (v1.21.0)
2026-08-03 22:23:44 -04:00
wmantly b46b3bed80 fix: use app.messages.confirm instead of native confirm() in vault Shared tab
The no_native_dialogs regression test forbids native alert/confirm/prompt in
views (they block browser events). Replace the native confirm() in deleteShared
with app.messages.confirm().
2026-08-03 22:19:38 -04:00
wmantly 948fef4adc fix vault 403 + shared secrets (v1.21.0)
- vault_broker: always reconcile policy content before serving a cached
  token (compare-and-skip), so stale stored policies can't cause a recurring
  403 'permission denied'; policy content is parsed live by OpenBao, so edits
  apply to existing tokens immediately.
- Shared secrets: publish to secret/shared/<owner>/<slug>; grant read to users
  and apps by editing the grantee's policy content (live-applied). New
  SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a
  Shared tab in the vault UI.
- package.json + lockfile bumped to 1.21.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:13:58 -04:00
wmantly 2612b0e3ab Merge pull request #159 from theta42/fix/sync-version-v1.20.2
fix: sync package version to v1.20.2 tag
2026-08-03 21:35:14 -04:00
wmantly bf471c2e19 fix: sync package version to v1.20.2 tag
The v1.20.2 release tag was created but nodejs/package.json (and the
lockfile) were left at 1.20.1, so the deployed app's buildVersion lagged
its own release tag and the update-check banner falsely reported a newer
version. Bump the version fields to match the tag.
2026-08-03 21:26:55 -04:00
wmantly d802c399a3 Merge pull request #157 from theta42/fix/sso-vault-conf-directory-v1.20.2
fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
2026-08-03 15:30:49 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
36 changed files with 2623 additions and 716 deletions
+3
View File
@@ -19,6 +19,9 @@
!API.md
!directory_spec.md
!docs/**/*.md
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
# in Dockerfile.openldap needs these present in the build context.
!docs/images/**
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
+56
View File
@@ -1,3 +1,59 @@
# v1.27.0
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function``update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
# v1.26.1
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
# v1.26.0
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
# v1.25.0
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
# v1.24.0
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
- feat: Directory resource slug is now read-only and derived from the name
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
# v1.23.0
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
# v1.22.0
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
# v1.21.0
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
- feat: shared-secrets API + Shared tab in the vault UI
# v1.20.0
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
## v1.19.0
- Added WebSocket endpoint for theta-agent C2
+5
View File
@@ -184,6 +184,11 @@ COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
# fs.readFileSync in routes/docs.js (files missing from the image).
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
+12 -8
View File
@@ -355,7 +355,11 @@ EOF
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
# app_sso_service_account is a marker (not a permission gate) for
# non-person accounts -- see the Users page.
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
#
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
# group-inheritance lattice. It is seeded here so it exists from first boot;
# the theta-suite bootstrap puts the first admin person into it.
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
EOF
done
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
# hold those rights by membership rather than by a special case in app code.
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
# anything binding LDAP directly -- instead of only to callers that happen
# to route through utils/permission.js.
# Nest god_admin into the SSO admin groups, so god admins hold those rights
# by membership rather than by a special case in app code. This is what makes
# the privilege visible to every consumer -- SSSD, sudo, anything binding
# LDAP directly -- instead of only to callers that happen to route through
# utils/permission.js.
#
# app_sso_service_account is deliberately excluded: it is a marker for
# non-person accounts, not a permission, and nesting admins into it would
@@ -381,10 +385,10 @@ EOF
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
info "Nested god_admin into the SSO admin groups"
fi
info "LDAP directory initialized"
+44
View File
@@ -31,6 +31,25 @@ Every 30 seconds, the agent streams real-time performance metrics:
---
## Viewing in the SSO Manager
Agent status and telemetry live on the **Directory** page — there is no separate
Agents page. For each **host** resource that has a connected theta-agent, the
Directory shows a status dot in the row:
| Color | Meaning |
| :--- | :--- |
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
| **Red** | Not connected (no agent, or the agent is offline). |
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the
resource name), so name the Directory host the same as the machine's hostname.
---
## Local-First Security & Capability Matrix
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
@@ -90,3 +109,28 @@ capabilities:
arbitrary_bash: false
```
---
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
connecting to `wss://<sso-host>/api/agent/ws`, the WebSocket path is usually
fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind
the same NAT that owns the SSO often cannot reach its own **public IP** (no
hairpin/loopback NAT on many home routers), so the TCP dial times out even
though the same address works from outside.
Fix options:
1. Point `agent.yml` `server_url` at an address the host can reach directly —
e.g. the SSO host's LAN IP (`http://<lan-ip>` or `http://<lan-ip>:3001` for a
no-TLS direct path).
2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach
their own public IP:443.
3. Add a local route/firewall rule on the agent host for its public IP.
> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make
> sure the proxy has a **persistent Host record** for the real SSO domain — not
> just the `localtest.me` placeholder — so routing survives a proxy restart
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
+312
View File
@@ -0,0 +1,312 @@
---
layout: default
title: Group & Permission Model
nav_order: 3
---
# Theta42 Group & Permission Model
This is the canonical reference for how **groups and permissions work** across the
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
hosts** should read and use them. It is written to be implementable by both humans
and LLM agents.
Everything below assumes LDAP is the single source of truth for identity and group
membership. Group membership is managed in the **SSO Manager Directory**, generated
from adopted resources — there is **no standalone "Groups" page**.
---
## 1. Principles
1. **Groups are a projection of the resource graph.** Every adopted host and app
in the Directory gets its own groups, auto-created from its identity. Group
membership is managed on the resource's modal.
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
hosts; an app administers apps. They do not inherit from each other.
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
`admin` implies `access`. Capabilities are explicit and never implied by
`admin`.
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
the site slug.
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
bloat. Only the meta groups are never imported by hosts.
6. **The directory is the only place groups are created.** `god_admin` is the sole
group that does not belong to a resource or site.
---
## 2. Group schema
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
`<capability>` = an opaque, app-defined capability token (see §4).
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
| `S_host_<host>_access` | host | Access to host `<host>`. |
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
| `S_apps_access` | site | Access to **all apps** at `S`. |
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
| `S_app_<app>_access` | app | Access to app `<app>`. |
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
### Meta groups (implicit membership — not POSIX, no gidNumber)
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `everyone` | global | **All authenticated users**, any site. |
| `S_everyone` | site | **All authenticated users** at site `S`. |
These are resolved by the directory (any authenticated user passes), never
enumerated as LDAP members, and cannot be used as Unix groups.
---
## 3. Naming, normalization & reserved rules
- The **structural delimiter is `_`**. It appears only between the fixed segments
of a group name.
- **Site, host, and app slugs never contain `_`.** Normalize to lowercase;
spaces and `_``-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a
site `Main Office` produce slugs `web-01` and `main-office`.
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
if a host were named `admin` (that host would be `S_host_admin_admin`).
- **The last segment is the level.** If it is `admin` or `access` it is a known
level; any other value is an **opaque capability** owned by a downstream app.
- **Total length budget:** keep a group cn under ~120 chars; reject group
creation that would exceed it.
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
generated on the host by SSSD for only the groups that host imports (see §8).
---
## 4. Levels and opaque capabilities
- **`admin`** — manage (create/update/delete/config) the resource.
- **`access`** — use/read the resource.
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
manages membership and exposes the group to the app; **the downstream app
defines and enforces what the capability means** (e.g. `emby_admin`,
`gitea_maintain`, `reboot`, `backup`).
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
Everything else on a resource group is treated as an opaque capability group and
passed through to consumers.
---
## 5. Permission resolution (inheritance)
Define a user's **effective permission** on a resource by checking, from most
specific to most general, whether they are a member of any applicable group. The
rule: a higher group implies everything below it.
### On host `H` at site `S`
| Wanted | Granted if the user is a member of **any** of |
| :--- | :--- |
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
### On app `A` at site `S`
Identical, with `app`/`apps` substituted for `host`/`hosts`.
### Management console (SSO / Proxy / Jump-Host)
Each console is registered as an **app** on its site, so console admin is:
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
### Pseudocode
```
def effective(resource, level_or_cap, site):
if user in "god_admin": return True
if user in f"{site}_super_admin": return True
if level_or_cap in ("admin","access"):
agg = f"{site}_{resource.kind}s_{level_or_cap}"
if user in agg: return True
specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}"
if user in specific: return True
if level_or_cap == "access": return effective(resource, "admin", site)
if level_or_cap == "admin": return False # access does not imply admin
return False
```
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
---
## 6. Where groups live — the Directory, generated from adopted resources
- There is **no standalone Groups page.** Group creation/management happens on an
**adopted resource** in the Directory.
- When a host or app is **adopted** (promoted from Discovered Inventory to
managed), the directory auto-creates its `_admin` and `_access` groups (and
site aggregates if configured). Capability groups are created on demand.
- Membership (add/remove users) and capability grants are managed on that
resource's modal.
- Deleting a resource removes its per-resource groups.
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
global `god_admin`/`everyone` are managed at the site level (not on a single
host/app resource).
---
## 7. Multi-site isolation
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
`everyone` cross site boundaries.
---
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
There are hundreds of them and only a handful matter on any given host, so we do
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
This keeps the directory clean and the per-host surface tiny.
### SSSD — generate GIDs on the fly, import only what you need
```ini
[domain/example]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example
ldap_search_base = dc=example,dc=com
# groupOfNames (RFC 2307bis) schema
ldap_schema = rfc2307bis
ldap_group_object_class = groupOfNames
ldap_group_member = member
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
ldap_id_mapping = true
ldap_group_uuid = entryUUID
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
```
Key ideas:
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
stable GID for any group it imports, so **no `gidNumber` attribute is required**
in LDAP.
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
match, discarding the other hundreds. After changing the filter, clear the
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
`getent group <cn>`.
### What filter to use — the naming convention is the answer
A host should import its **own** resource groups (plus any explicitly granted
ones). Because the schema is predictable, `ldap-client` can generate the per-host
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
at site `main-office` imports:
```
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
(cn=main-office_host_web01_admin)
(cn=main-office_host_web01_sudo)))
```
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
### Aliasing an LDAP group into a local group (e.g. `input`)
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
Two host-side mechanisms cover it:
- **pam_exec** — a script in the login stack adds the user to the local group for
the session:
```sh
#!/bin/bash
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
```
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
`/etc/pam.d/common-session`.
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
members regardless of the local GID.
### Meta groups
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
implicit membership and are resolved by the directory only.
---
## 9. Downstream-app consumption guide
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
membership from LDAP and interprets it as follows:
1. **Discover the user's groups** — bind with the user's credentials (or use a
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
the `memberOf` reverse attribute on the user's entry.
2. **Match each group to a scope:**
- `god_admin` → the user is a global administrator.
- `{site}_super_admin` → site administrator for that site.
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
- `everyone` / `{site}_everyone` → the user is implicitly a member.
3. **Interpret the last segment:**
- `admin` → full control of that resource.
- `access` → read/use.
- anything else → a capability **you** define; act on it or ignore it.
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
`{site}_app_emby_emby_admin` administers Emby.
The app must **never** treat an unknown last segment as `admin` or `access`.
---
## 10. Migration from the legacy `app_*` groups
The current global groups (`app_sso_admin`, `app_super_admin`,
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
| Legacy | New |
| :--- | :--- |
| `app_super_admin` | `god_admin` |
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
| `app_sso_directory_admin` | `S_app_sso_admin` |
| `app_jump_admin` | `S_app_jump_admin` |
During the transition the legacy groups may be kept as short-lived aliases that
resolve to the same effective permission; once everything is moved, remove them.
---
## 11. The management consoles are apps
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
self-consistent: the SSO is "just another app."
+18
View File
@@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar.
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## Apps tab (admin)
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
## Shared tab
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
+2
View File
@@ -126,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
const vaultBroker = require('./utils/vault_broker');
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
// Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called.
+7
View File
@@ -60,6 +60,13 @@ models.initORM().then(() => {
initScheduler(conf.discovery).catch(err => {
console.error('Failed to initialize scheduler:', err);
});
// Keep external-app vault tokens alive: renew every stored accessor now and
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
if (process.env.VAULT_TOKEN) {
require('../utils/vault_broker').startAppTokenRenewal();
}
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
+36
View File
@@ -17,6 +17,9 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -31,15 +34,48 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
await healSchema();
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
// which creates missing tables but never touches existing ones — so a column
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
// in an upgraded deployment's database and every query on the model fails
// ("no such column"). This walks each Sequelize model and ADDs any attribute
// missing from its table. Strictly additive (never drops or retypes), works on
// any dialect via the query interface, and fail-soft per column so one bad
// attribute can't take the boot down.
async function healSchema() {
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
if (!adapter || !adapter.sequelize) return;
const sequelize = adapter.sequelize;
const qi = sequelize.getQueryInterface();
for (const SM of Object.values(sequelize.models)) {
const table = SM.getTableName();
let existing;
try { existing = await qi.describeTable(table); }
catch (e) { continue; } // no table yet — sync() handles creation
for (const [name, attr] of Object.entries(SM.getAttributes())) {
const col = attr.field || name;
if (existing[col]) continue;
try {
await qi.addColumn(table, col, attr);
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
} catch (e) {
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
}
}
}
}
module.exports.initORM = initORM;
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// SharedSecret — a secret the owner has published to the shared namespace so it
// can be shared with other users and/or downstream apps.
//
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
// never in the DB. This row is metadata only (owner + slug + description) and is
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
// edited to add `read` on the exact shared path (see vault_broker.js — policy
// content is parsed live at token use, so a grant takes effect immediately with
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
//
// `slug` is unique and immutable in practice — it is embedded in the shared path
// and in grantee policy rules, so changing it would require rewriting policies.
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
// ORM on create.
const { Model } = require('@simpleworkjs/orm');
class SharedSecret extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
// Unique so two owners can't collide on the same shared path.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The publishing user's uid — also the shared path's namespace segment.
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
// Optional human description shown in the Shared tab.
description: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
static pathFor(ownerUid, slug) {
return `shared/${ownerUid}/${slug}`;
}
path() {
return SharedSecret.pathFor(this.ownerUid, this.slug);
}
// Look up by slug (unique). Returns the row or null.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { SharedSecret };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
// <secretId>".
//
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
// content live at token use, the grant applies to the grantee's existing token
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
//
// granteeType distinguishes the two principal kinds:
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
// capability is currently always 'read' (grantees are read-only); the column is
// a string so later capabilities could be added without a migration.
//
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
const { Model } = require('@simpleworkjs/orm');
const GRANTEE_TYPES = ['user', 'app'];
const CAPABILITIES = ['read'];
class SharedSecretGrant extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// FK to SharedSecret.id.
secretId: { type: 'string', isRequired: true, min: 1 },
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
granteeType: { type: 'string', isRequired: true, min: 1 },
// The grantee's uid (for 'user') or app name (for 'app').
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
// Access level — 'read' today.
capability: { type: 'string', isRequired: true, default: 'read' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All grants for a given grantee (user uid or app name). Used to rebuild the
// grantee's policy content so every granted shared path is present/absent.
static async listForGrantee(granteeType, granteeId) {
return this.list({ where: { granteeType, granteeId } });
}
}
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
+43
View File
@@ -0,0 +1,43 @@
'use strict';
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
// from the vault UI (Apps tab), so sso can keep the token alive.
//
// The token itself is shown ONCE at mint and never stored (a stolen accessor
// cannot authenticate — it can only look up, renew, or revoke its token, and
// only the sso broker's policy grants those endpoints). App tokens are minted
// through the sso-app role as PERIODIC tokens: they live forever, but only if
// something renews them inside every period window. That something is sso's
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
// stays valid as long as sso itself is running, with no renewal code needed in
// the downstream app.
//
// One row per app name: re-minting an app's token revokes the previous token
// via its accessor (no zombie credentials) and replaces the row.
const { Model } = require('@simpleworkjs/orm');
class VaultAppToken extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// The external app's name — also its policy (app-<name>) and KV namespace
// (secret/apps/<name>/). Unique: one live token per app.
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The minted token's accessor (renew/revoke handle, cannot authenticate).
accessor: { type: 'string', isRequired: true, max: 128 },
// Renewal bookkeeping, updated by the renewal loop.
lastRenewedAt: { type: 'integer' },
lastError: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
};
static async getByName(name) {
const rows = await this.list({ where: { name } });
return rows[0] || null;
}
}
module.exports = { VaultAppToken };
+18 -18
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.20.0",
"version": "1.27.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -2344,9 +2344,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -4294,9 +4294,9 @@
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -5966,16 +5966,16 @@
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/nodemon/node_modules/debug": {
@@ -7726,9 +7726,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7918,9 +7918,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"optional": true,
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.27.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+6
View File
@@ -3,6 +3,12 @@ nav.navbar{
padding-right: 1em;
}
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body {
display: flex;
flex-direction: column;
+26 -7
View File
@@ -1,15 +1,18 @@
'use strict';
const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
return;
}
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
app.wss.on('connection', (ws, req) => {
module.exports = function initAgentWebSockets(app) {
// Only the WebSocket handler needs the WS server. The REST routes mounted
// below (/api/agent/*) must work regardless of the WS server state -- gating
// them on `app.wss` made them 404 whenever it wasn't initialized.
if (app.wss) {
app.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
@@ -70,9 +73,25 @@ module.exports = function initAgentWebSockets(app) {
}));
} catch (e) {}
});
} // end if (app.wss)
// REST API routes for Agent Management (mounted under /api/agent)
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
// bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
const router = express.Router();
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
router.get('/nodes', (req, res) => {
res.json({
+228 -42
View File
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
const groups = require('../utils/groups');
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
// transitively in the parent. Idempotent and non-fatal: "already a member" is
// the goal state, and a missing group (e.g. app_super_admin absent on a
// the goal state, and a missing group (e.g. god_admin absent on a
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
// caller's real work.
async function nestGroup(childCn, parentCn) {
@@ -29,6 +30,160 @@ async function nestGroup(childCn, parentCn) {
}
}
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
// The directory is the single place groups are created, as a projection of the
// resource graph. These helpers materialize the group-inheritance lattice for
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
// All of them are idempotent, so calling them again for a resource a newer
// release is backfilling is a no-op.
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
// host -> host; service -> app (services/consoles are the group model's "apps");
// site gets site-level groups (handled separately); oauth/container get no
// per-resource groups (oauth clients hang off their owning service).
function groupKind(resource) {
if (resource.kind === 'host') return 'host';
if (resource.kind === 'service') return 'app';
return null;
}
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
// seeds the mandatory first member. Returns true when created.
async function ensureGroup(name, ownerDn, description) {
try {
await Group.add({ name, owner: ownerDn, description });
return true;
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`ensureGroup: failed to create ${name}:`, err);
}
return false;
}
}
// Link a group to a resource only if that link doesn't already exist. The
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
// naive create on every Directory self-heal (which runs ensureSiteGroups /
// provisionResourceGroups on each load) was accumulating duplicate links -- the
// "groups appear 3x under a resource" bug. Always check first.
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
if (existing.length) return existing[0];
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
}
// Provision the site-level groups + the aggregates the per-resource groups nest
// into. Idempotent -- called on every directory list so a site seeded by an
// older release gets its groups without a rebuild:
//
// god_admin -> {site}_super_admin
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
//
// `{site}_everyone` is created for completeness; it has implicit membership and
// is granted to a resource as a grantee, never enumerated.
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
if (!siteSlug) return;
// Link a site group to the site resource (so it shows + is member-manageable
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
// groups as member.
const link = async (cn, isAdmin) => {
if (!siteResourceId) return;
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
};
const sAdmin = groups.siteSuperAdminCns(siteSlug);
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
await link(sAdmin, true);
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
// them), but are NOT linked to the site resource: a site carries only the god
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
// aggregates have no modal home; site-wide access is granted via S_super_admin
// and per-resource access via the host/app groups.
for (const kind of ['host', 'app']) {
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
}
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
await link(groups.siteEveryoneCns(siteSlug), false);
// god_admin is the global group; surface it on the site modal so its members
// can be managed from the Directory (it has no home on a single resource).
await link(groups.GOD_ADMIN, true);
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
// binding directly) resolve it transitively, not just utils/permission.js.
// nestGroup(child, parent) makes child a member of parent -- membership flows
// child -> parent ("up"), so a group's members inherit what its parents hold.
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
for (const kind of ['host', 'app']) {
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
}
}
// Provision the per-resource groups for a host/app and nest them into the site
// aggregates (so a site/aggregate admin reaches this resource by membership).
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
//
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
const nameSlug = groups.resourceNameSlug(resource.slug);
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
// Link both groups to the resource so the Directory can show/revoke them.
await ensureResourceGroup(resource.id, accessCn, 'member');
await ensureResourceGroup(resource.id, adminCn, 'owner');
await nestGroup(adminCn, accessCn); // administering implies using
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
}
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
// §2/§3). This is what "force the correct naming convention" means: a group
// linked to a resource must be one that parses for consumers -- the resource's
// own specific groups, its site's aggregates, site-level groups, or the global
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
// capability groups following the same shapes.
function validGroupCnsForResource(resource, siteSlug) {
const valid = new Set();
// A site resource only carries god_admin (added by the route) + the site-wide
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
// specific groups belong to host/app resources, not to the site.
if (resource.kind === 'site') {
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
}
const kind = groupKind(resource); // 'host'|'app'|null
if (kind) {
const nameSlug = groups.resourceNameSlug(resource.slug);
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
}
// oauth/container etc. — only the global god_admin makes sense to pin here.
valid.add(groups.siteSuperAdminCns(siteSlug));
return { valid, capRe: null };
}
// Require the admin group
router.use(async (req, res, next) => {
try {
@@ -50,6 +205,36 @@ router.get('/resources', async (req, res, next) => {
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
// aggregates, and every host/app resource has its per-resource groups nested
// into them. Idempotent, so this is a cheap no-op once present -- it's what
// backfills a directory seeded by an older release without a rebuild.
// Never fails the list.
const sites = resources.filter(r => r.kind === 'site');
await Promise.all(sites.map(site =>
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
));
const siteByResource = new Map();
for (const site of sites) siteByResource.set(site.id, site.slug);
const siteOf = async (r) => {
const direct = siteByResource.get(r.id);
if (direct) return direct;
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
};
await Promise.all(resources.map(async (r) => {
const gKind = groupKind(r);
if (!gKind) return;
const siteSlug = await siteOf(r);
if (!siteSlug) return;
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
}));
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
@@ -95,46 +280,25 @@ router.post('/resources', async (req, res, next) => {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
const createGroup = async (suffix, accessLevel) => {
const cn = groupCn(suffix);
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
// Wire up the two standing relationships every resource has, as nesting
// rather than as membership that has to be maintained per resource:
//
// app_super_admin -> <slug>_admin cross-app super admins administer
// every resource, automatically
// <slug>_admin -> <slug>_access administering something implies
// being able to use it
//
// Before nesting, both of these could only be expressed by adding every
// super admin to every new group by hand -- which nobody does, so the
// groups drifted. A failure here must not fail resource creation: the
// resource and its groups already exist and the nesting is repairable.
await nestGroup(groupCn('admin'), groupCn('access'));
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
// Materialize the group-model for the new resource. Site resources get the
// site-level groups; host/app resources get their per-resource groups nested
// into the site aggregates. Idempotent -- safe for a resource created by an
// older release. A provisioning failure must not fail resource creation: the
// resource already exists and the groups are repairable (re-run ensures them).
//
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
// the kind prefix themselves.
const gKind = groupKind(r);
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
if (r.kind === 'site') {
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
} else if (gKind && ancestorSite) {
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
@@ -265,7 +429,29 @@ router.get('/groups', async (req, res, next) => {
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
const { resourceId, groupCn } = req.body;
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
// be a valid group for this resource; reject free-form names so the groups
// consumers read are always parseable. god_admin is always allowed (it is
// the global group and is managed from a site's modal).
const resource = await Resource.get(resourceId);
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
// site resource's own slug is its site; a host/app uses its ancestor site.
const siteSlug = resource && resource.kind === 'site'
? resource.slug
: await Resource.findAncestorSiteSlug(resourceId);
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
err.status = 400;
throw err;
}
}
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
res.json({ results: g });
} catch (err) { next(err); }
});
@@ -313,7 +499,7 @@ router.get('/access-summary', async (req, res, next) => {
//
// Counts come from the transitive closure, not from `member`. Reading the
// attribute would report only who is listed on the group, missing anyone
// who reaches it through a nested group -- and since app_super_admin is
// who reaches it through a nested group -- and since god_admin is
// nested into every resource's _admin group, that is not an edge case.
let members = [];
if (group) {
+213
View File
@@ -0,0 +1,213 @@
'use strict';
// Shared-secrets API.
//
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
// OpenBao policy content is edited to add read on the exact shared path (see
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
// immediately, with no token re-mint.
//
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
// them through the existing /api/vault proxy using the requester's own session
// token, so OpenBao ACL enforces read access per-request. This router handles
// metadata CRUD + grant management; KV writes (create/update/delete) are made
// server-side using the acting user's scoped token.
const express = require('express');
const baoConf = require('@simpleworkjs/bao-conf');
const permission = require('../utils/permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const vaultBroker = require('../utils/vault_broker');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
// only reject values that can't be a sane secret path segment (spaces, slashes,
// leading non-alnum, too long).
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const router = express.Router();
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
// /api/vault proxy — personal, per-user secret management only).
router.use((req, res, next) => {
if (req.user && req.user.isMachine) {
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
}
next();
});
async function isAdmin(user) {
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
catch (e) { return false; }
}
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
// sso-admin token (R/W on secret/*).
async function actorToken(user, ownerUid) {
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
return null;
}
// Does this user manage the given shared secret? Owner or admin.
async function canManage(user, secret) {
if (user.uid === secret.ownerUid) return true;
return isAdmin(user);
}
async function loadSecret(req, res) {
const secret = await SharedSecret.get(req.params.id);
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
return secret;
}
// ── List: mine + shared-with-me ─────────────────────────────────────────────
router.get('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
const grants = await SharedSecretGrant.listForGrantee('user', uid);
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
const granted = granteeSecretIds.length
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
for (const g of granted) {
if (byId.has(g.id)) continue; // already owner
byId.set(g.id, { role: 'grantee', ...g });
}
// The `{ role, ...s }` spread above copies only own properties, so the
// instance method `path()` is dropped -- call the static builder instead.
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
} catch (e) { next(e); }
});
// ── Create ──────────────────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const slug = String(req.body.slug || '').trim().toLowerCase();
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
const description = String(req.body.description || '').trim();
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
if (await SharedSecret.getBySlug(slug)) {
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
}
const token = await actorToken(req.user, uid);
if (!token) return res.status(403).json({ error: 'not allowed' });
const path = SharedSecret.pathFor(uid, slug);
await baoConf.set(path, data, { token });
const secret = await SharedSecret.create({
slug, ownerUid: uid, description,
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
});
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
} catch (e) { next(e); }
});
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
router.get('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
const uid = req.user.uid;
const admin = await isAdmin(req.user);
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Update data / description ───────────────────────────────────────────────
router.put('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
const update = {};
if (req.body && typeof req.body.data === 'object') {
await baoConf.set(secret.path(), req.body.data, { token });
}
if (req.body && req.body.description !== undefined) {
update.description = String(req.body.description).trim();
}
if (Object.keys(update).length) {
update.updated_by = req.user.uid;
update.updated_on = Date.now();
await secret.update(update);
}
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
} catch (e) { next(e); }
});
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
// Revoke all grants first so grantees' policies drop the path.
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
// Delete the KV data (metadata delete removes all versions), then the row.
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
await secret.delete();
res.status(204).end();
} catch (e) { next(e); }
});
// ── Grants: list ────────────────────────────────────────────────────────────
router.get('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Grants: create ──────────────────────────────────────────────────────────
router.post('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const granteeType = String(req.body.granteeType || '').trim();
const granteeId = String(req.body.granteeId || '').trim();
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
if (granteeId === secret.ownerUid && granteeType === 'user') {
return res.status(400).json({ error: 'the owner already has access' });
}
// Idempotent: skip if the grant already exists.
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
} catch (e) { next(e); }
});
// ── Grants: revoke ──────────────────────────────────────────────────────────
router.delete('/:id/grants/:grantId', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grant = await SharedSecretGrant.get(req.params.grantId);
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
res.status(204).end();
} catch (e) { next(e); }
});
module.exports = router;
+5 -2
View File
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await Resource.update(resource.id, { metadata: meta });
// `Resource.update` is not a static — `update` is an instance method
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
const inst = await Resource.get(resource.id);
await inst.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
+2 -1
View File
@@ -37,6 +37,7 @@ const DOCS = {
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
@@ -54,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
// only resolves correctly on GitHub. Serve that same folder here and rewrite
// the rendered markup to point at it absolutely, so the images work when
// read from /docs/overview too.
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
-4
View File
@@ -188,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) {
res.render('profile', {...values});
});
router.get('/groups', function(req, res, next) {
res.render('groups', {...values});
});
router.get('/token', function(req, res, next) {
res.render('token', {...values});
});
+7 -1
View File
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
// same answer in both modes.
const groups = await groupCns(user);
user.groups = groups;
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
// legacy app_sso_admin/app_super_admin during migration.
user.isAdmin = groups.some((g) =>
g === 'app_sso_admin' || g === 'app_super_admin' ||
g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP ||
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
return res.json(user);
}catch(error){
+7 -5
View File
@@ -44,9 +44,10 @@ beforeAll(async () => {
expect(host.status).toBe(200);
hostId = host.body.results.id;
// Creating a host auto-provisions <site>_<slug>_access / _admin.
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
// (docs/GROUPS.md §2 — the kind is part of the name).
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
// The creator is seeded into both groups -- groupOfNames requires at least
// one member, so Group.add puts the owner's DN there -- and _admin is nested
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
expect(host.status).toBe(200);
// Same as the top-level setup: step out of the auto-created groups the
// creator is seeded into, or this is a request for access already held.
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
// or this is a request for access already held.
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
await request(app)
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
.set('auth-token', token);
+130
View File
@@ -0,0 +1,130 @@
'use strict';
const {
slugify,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
GOD_ADMIN,
} = require('../utils/groups');
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
describe('slugify', () => {
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
expect(slugify('Web 01')).toBe('web-01');
expect(slugify('Main Office')).toBe('main-office');
expect(slugify('my_host')).toBe('my-host');
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
expect(slugify('')).toBe('');
});
});
describe('group cn builders', () => {
test('per-resource names the kind + name slug (docs §2)', () => {
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
});
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
});
test('aggregate uses the plural kind', () => {
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
});
test('site super admin + everyone', () => {
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
});
test('a directory site slug with a kind prefix is kept verbatim', () => {
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
});
test('invalid kind throws', () => {
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
});
});
describe('levels', () => {
test('admin/access known; capabilities opaque', () => {
expect(isKnownLevel('admin')).toBe(true);
expect(isKnownLevel('access')).toBe(true);
expect(isKnownLevel('reboot')).toBe(false);
expect(isKnownLevel('emby_admin')).toBe(false);
});
test('admin implies access; access does not imply admin', () => {
expect(levelGrants('admin', 'access')).toBe(true);
expect(levelGrants('access', 'admin')).toBe(false);
});
});
describe('hasPermission — inheritance', () => {
test('god_admin grants everything everywhere', () => {
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
});
test('site super admin grants everything on its site, not other sites', () => {
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
});
test('aggregate (all hosts) grants on any host at the site', () => {
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
});
test('specific host group grants only that host', () => {
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
});
test('admin implies access; access does not imply admin', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
});
test('capabilities are exact — admin does not grant a capability', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
});
test('hosts and apps are orthogonal namespaces', () => {
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
});
test('a service maps to the app kind (docs §11)', () => {
// The directory `service` kind is the group model's `app`.
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
});
test('cross-site isolation', () => {
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
});
});
+155 -2
View File
@@ -15,8 +15,36 @@ jest.mock('redis', () => ({
})
}));
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
// can run without a database.
jest.mock('../models/shared_secret', () => ({
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/shared_secret_grant', () => ({
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/vault_app_token', () => {
const rows = [];
const VaultAppToken = {
_rows: rows,
list: jest.fn(async () => rows),
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
create: jest.fn(async (data) => {
const row = {
...data,
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
};
rows.push(row);
return row;
}),
};
return { VaultAppToken };
});
const baoConf = require('@simpleworkjs/bao-conf');
const vaultBroker = require('../utils/vault_broker');
const { VaultAppToken } = require('../models/vault_app_token');
describe('vault_broker admin policy', () => {
beforeEach(() => {
@@ -29,8 +57,8 @@ describe('vault_broker admin policy', () => {
return { status: 404, text: async () => '' };
}
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }');
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
return { status: 204, ok: true };
}
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
@@ -49,3 +77,128 @@ describe('vault_broker admin policy', () => {
}));
});
});
describe('app token lifecycle (accessor storage + renewal)', () => {
beforeEach(() => {
baoConf.request.mockReset();
VaultAppToken._rows.length = 0;
});
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
baoConf.request.mockImplementation(async (method, path, body) => {
if (path.startsWith('sys/policies/acl/')) {
if (method === 'GET') return { status: 404, text: async () => '' };
return { status: 204, ok: true };
}
if (path === 'auth/token/create/sso-app') {
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
}
if (path === 'auth/token/renew-accessor') {
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
}
if (path === 'auth/token/revoke-accessor') {
return { ok: true, status: 204, text: async () => '' };
}
return { status: 200, ok: true, json: async () => ({}) };
});
}
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
mockBao({ mintAccessor: 'acc-old' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
mockBao({ mintAccessor: 'acc-new' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
});
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
mockBao();
await vaultBroker.mintAppToken('demo', 'adminuser');
VaultAppToken._rows[0].lastRenewedAt = 0;
await vaultBroker.renewAppTokens();
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
expect(VaultAppToken._rows[0].lastError).toBeNull();
});
test('renewAppTokens records the failure on the row without throwing', async () => {
mockBao({ renewOk: false });
await vaultBroker.mintAppToken('demo', 'adminuser');
await vaultBroker.renewAppTokens();
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
});
});
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
// This exists because the proxy once shipped with a hook shape the installed
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
// request 403'd. A unit test on options can't catch that — only a wire test can.
describe('vaultProxy wire behavior', () => {
const http = require('http');
const express = require('express');
let target; // fake OpenBao
let seen; // last request the fake OpenBao received
let app; // sso app fragment: scopeGuard stub + vaultProxy
let server;
beforeAll((done) => {
target = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
seen = { method: req.method, url: req.url, headers: req.headers, body };
res.setHeader('content-type', 'application/json');
res.end('{"ok":true}');
});
});
target.listen(0, '127.0.0.1', () => {
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
jest.resetModules();
const broker = require('../utils/vault_broker');
app = express();
app.use(express.json());
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
server = app.listen(0, '127.0.0.1', done);
});
});
afterAll((done) => {
server.close(() => target.close(done));
});
function call(path, opts = {}) {
const port = server.address().port;
return fetch(`http://127.0.0.1:${port}${path}`, opts);
}
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
});
expect(res.status).toBe(200);
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(seen.headers['auth-token']).toBeUndefined();
expect(seen.headers['authorization']).toBeUndefined();
});
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
const res = await call('/api/vault/secret/data/users/alice/foo', {
method: 'POST',
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
body: JSON.stringify({ data: { hello: 'world' } }),
});
expect(res.status).toBe(200);
expect(seen.method).toBe('POST');
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
});
});
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// Theta42 group & permission model.
//
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
// parseable structure. The structural delimiter is `_`; site/host/app slugs
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
// uses the singular (host/app).
//
// god_admin global — everything, everywhere
// {site}_super_admin everything on the site
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
// {site}_hosts_<level>
// {site}_host_<slug>_<level> admin/access/capability on ONE host
// {site}_apps_<level> ... on ALL apps at the site
// {site}_app_<slug>_<level> ... on ONE app
// {site}_everyone / everyone meta groups (implicit membership)
//
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
// `access`; capabilities are explicit and never implied by `admin`. Groups are
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
//
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
// supply the user's group memberships (e.g. from Group.list(user.dn)).
const GOD_ADMIN = 'god_admin';
const KNOWN_LEVELS = ['admin', 'access'];
const KINDS = ['host', 'app'];
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
// contains '_' (the structural delimiter), so group names parse unambiguously.
function slugify(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Validate a kind (host/app) — throw on anything else.
function assertKind(kind) {
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
}
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
// `theta-env`), leaving the resource's name slug. Services are stored bare
// (`sso-manager`), so this is a no-op for them.
function resourceNameSlug(slug) {
return String(slug || '').replace(/^(site|host|app)_/, '');
}
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
// `site_local_app_sso-manager_access`.
function resourceGroupCns(site, kind, nameSlug, level) {
assertKind(kind);
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
}
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
function aggregateGroupCns(site, kind, level) {
assertKind(kind);
return `${site}_${kind}s_${level}`;
}
// {site}_super_admin
function siteSuperAdminCns(site) {
return `${site}_super_admin`;
}
// {site}_everyone
function siteEveryoneCns(site) {
return `${site}_everyone`;
}
// True if `level` is a known admin/access level (not an opaque capability).
function isKnownLevel(level) {
return KNOWN_LEVELS.includes(level);
}
// True if holding `level` grants `wanted` (admin implies access).
function levelGrants(level, wanted) {
if (level === wanted) return true;
return level === 'admin' && wanted === 'access';
}
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
// `level` on a resource. Applies the inheritance lattice:
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
//
// memberOf: array of group cns the user is a member of.
// resource: { site, kind: 'host'|'app', slug }.
// level: 'admin' | 'access' | an opaque capability token.
//
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
// are resource-level grants, resolved by the caller against the resource's own
// granted groups (see permission.onResource). This keeps the function pure over
// the user's membership only.
function hasPermission(memberOf, resource, level) {
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
// apps); `nameSlug` is the resource name with any kind prefix stripped.
const site = resource && resource.site;
const rawKind = resource && resource.kind;
const kind = rawKind === 'service' ? 'app' : rawKind;
const nameSlug = resourceNameSlug(resource && resource.slug);
const set = new Set(memberOf || []);
if (set.has(GOD_ADMIN)) return true;
if (set.has(siteSuperAdminCns(site))) return true;
if (isKnownLevel(level)) {
// admin / access
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
return false;
}
// Opaque capability — exact aggregate or specific grant only.
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
return false;
}
module.exports = {
GOD_ADMIN,
KNOWN_LEVELS,
KINDS,
slugify,
resourceNameSlug,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
};
+64 -5
View File
@@ -1,10 +1,27 @@
'use strict';
const {Group} = require('../models/group_ldap');
const groups = require('./groups');
const SUPER_ADMIN_GROUP = 'app_super_admin';
// The group nested into every resource's _admin group by api_directory_admin
// (cross-resource super-admin administration). This is `god_admin` -- the global
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
// It used to be the legacy `app_super_admin`, which existed while god_admin
// didn't; now that god_admin is created at boot, the provisioning nests it.
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
// the migration, so an existing deployment isn't stripped of rights until it's
// rebuilt.
const SUPER_ADMIN_GROUP = 'god_admin';
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
let byGroup = async function(user, groups, ownerOf){
// True if the user (by resolved member cns) is a global god/super admin.
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
async function isSuperAdmin(memberOfCns) {
return memberOfCns.includes(groups.GOD_ADMIN) ||
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
}
let byGroup = async function(user, checkGroups, ownerOf){
// Membership is resolved once, transitively: a user placed in an admin group
// through a nested group is as much a member as one listed on it directly.
// Checking `group.member.includes(user.dn)` per group -- as this used to --
@@ -17,9 +34,9 @@ let byGroup = async function(user, groups, ownerOf){
// they still catch direct membership if the resolver is unavailable.
}
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
if(await isSuperAdmin(memberOfCns)) return true;
for(let group of groups){
for(let group of checkGroups){
if(memberOfCns.includes(group)) return true;
}
@@ -42,4 +59,46 @@ let byGroup = async function(user, groups, ownerOf){
throw error;
}
module.exports = {byGroup, SUPER_ADMIN_GROUP};
// Resolve whether a user has `level` on a directory resource under the group
// model (see utils/groups.js). Applies the inheritance lattice and the
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
//
// user: the auth user ({ dn, isMachine }).
// resource:{ site, kind: 'host'|'app', slug }.
// level: 'admin' | 'access' | an opaque capability token.
// grantedGroups: optional array of the resource's granted group cns (used only
// for meta `everyone` handling). Omit to skip meta grants.
async function onResource(user, resource, level, grantedGroups) {
let memberOfCns = [];
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
if (await isSuperAdmin(memberOfCns)) return true;
if (groups.hasPermission(memberOfCns, resource, level)) return true;
// Meta grants: `everyone` / `{site}_everyone` confer access to any
// authenticated (non-machine) user when the resource grants them.
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
const siteEveryone = groups.siteEveryoneCns(resource.site);
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
}
return false;
}
// Like onResource but throws Insufficient Permission when denied — for guards.
async function requireResource(user, resource, level, grantedGroups) {
if (await onResource(user, resource, level, grantedGroups)) return;
const error = new Error('Insufficient Permission');
error.name = 'Insufficient Permission';
error.status = 401;
throw error;
}
module.exports = {
byGroup,
onResource,
requireResource,
isSuperAdmin,
SUPER_ADMIN_GROUP,
LEGACY_SUPER_ADMIN_ALIASES,
...groups, // group schema builders (slugify, resourceGroupCns, ...)
};
-1
View File
@@ -41,7 +41,6 @@ module.exports = {
// Catalog requires login - it's the end-user view of their accessible resources.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*.
+260 -62
View File
@@ -4,15 +4,25 @@
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
// `sso-broker` token role created by theta-env/setup.sh.
//
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/* admin UI sessions (sso-admin policy)
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
// secret/* admin UI sessions (sso-admin policy)
//
// The sso-broker policy grants update on auth/token/create/sso-broker and on
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
// create the per-subject policies and mint their tokens. Per-user/admin tokens
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
//
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
// live at token use (only the SET of policy names on a token is fixed at mint),
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
// — cached or freshly minted. That way a stale cached token immediately gains
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
// an existing grantee token with no re-mint. The Redis cache only short-circuits
// token MINTING, never policy reconciliation.
const baoConf = require('@simpleworkjs/bao-conf');
const { createClient } = require('redis');
@@ -20,6 +30,9 @@ const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const conf = require('@simpleworkjs/conf');
const permission = require('./permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { VaultAppToken } = require('../models/vault_app_token');
const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
@@ -54,52 +67,86 @@ async function bao(method, path, body) {
return res;
}
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
// a list grant on a directory path) propagate on the next vault-page visit
// without an operator re-running setup.sh. Skipping on an existing policy
// would strand the old, narrower HCL forever.
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
// content and only PUT when it differs. `bao policy write` is an idempotent
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
// grant) propagate immediately because OpenBao parses policy content at use.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
}
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
// Mint a token through the sso-broker role with the given policies. Returns
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
async function mintToken(policies) {
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
// Mint a token through a token role with the given policies. Returns
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
// from cache) and sso-app (768h period — long-lived external-app credentials,
// kept alive via their stored accessor by the renewal loop below).
async function mintToken(policies, role = ROLE) {
const res = await bao('POST', `auth/token/create/${role}`, { policies });
const json = await res.json();
const token = json && json.auth && json.auth.client_token;
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
return { token, ttl };
return { token, accessor: json.auth.accessor, ttl };
}
// ── Shared-secret policy rules ───────────────────────────────────────────────
// Returns the HCL rules granting `read` on every shared secret the given
// grantee (a user uid or an app name) has been granted. Enforcement is
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
// effect for the grantee's existing token immediately (no re-mint).
async function sharedPolicyRules(granteeType, granteeId) {
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
if (!grants.length) return '';
const secretIds = [...new Set(grants.map(g => g.secretId))];
const secrets = secretIds.length
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
const byId = new Map(secrets.map(s => [s.id, s]));
const rules = [];
for (const g of grants) {
const sec = byId.get(g.secretId);
if (!sec) continue;
const p = sec.path(); // shared/<ownerUid>/<slug>
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
}
return rules.join('\n');
}
// ── Per-user token ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
// contents of the namespace: `.../*` covers nested paths but NOT the
// directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
async function userPolicyHcl(uid) {
const granted = await sharedPolicyRules('user', uid);
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
// Re-minted when the cache entry expires (a little before the token's own TTL).
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
// (compare-and-skip) before the cache is consulted, so a cached token can never
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
// when the cache entry expires (a little before the token's own TTL).
async function getOrCreateUserToken(uid) {
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
const cacheKey = `vault_token:${uid}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
const { token, ttl } = await mintToken([`user-${uid}`]);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
@@ -107,47 +154,164 @@ async function getOrCreateUserToken(uid) {
// ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
// paths but NOT the root itself, so without it the /vault secrets list 403s.
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
}
async function getOrCreateAdminToken(uid) {
await ensurePolicy('sso-admin', adminPolicyHcl());
const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await ensurePolicy('sso-admin', adminPolicyHcl());
const { token, ttl } = await mintToken(['sso-admin']);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
}
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
async function appPolicyHcl(name) {
const granted = await sharedPolicyRules('app', name);
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately.
async function mintAppToken(name) {
// tokens. The caller must record it in the external app immediately. Later
// grants to the app edit app-<name> policy content (live-applied to this token).
//
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
// token alive and lets a re-mint revoke the app's previous token so exactly
// one credential per app is ever live.
async function mintAppToken(name, actorUid) {
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
}
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
// App tokens are long-lived credentials: mint via the sso-app role (768h
// period) so a renewal inside every 32-day window keeps them alive forever.
// Fall back to the broker's own 24h role on deployments whose setup.sh
// predates the sso-app role (re-running setup.sh creates it).
let minted;
try {
minted = await mintToken([`app-${name}`], 'sso-app');
} catch (e) {
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
minted = await mintToken([`app-${name}`]);
}
const { token, accessor, ttl } = minted;
// Replace the app's accessor row; revoke the superseded token (best-effort —
// it may already be expired) so re-minting never leaves a zombie credential.
try {
const existing = await VaultAppToken.getByName(name);
if (existing) {
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
await existing.delete();
}
if (accessor) {
await VaultAppToken.create({
name, accessor,
lastRenewedAt: Date.now(),
created_by: actorUid, created_on: Date.now(),
});
}
} catch (e) {
// Accessor bookkeeping must never block handing the token out; without a
// row the token simply isn't auto-renewed (it still lives one full period).
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
}
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── App-token renewal loop ──────────────────────────────────────────────────
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
// far inside both possible periods (24h fallback and 768h), so a downstream
// app's token stays valid for as long as sso is running. Failures are recorded
// on the row (visible to admins in the DB / future UI) and never throw.
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
let renewTimer;
async function renewAppTokens() {
let rows;
try { rows = await VaultAppToken.list(); }
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
for (const row of rows) {
try {
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
if (res.ok) {
await row.update({ lastRenewedAt: Date.now(), lastError: null });
} else {
const text = await res.text().catch(() => '');
// 400 "invalid accessor" = token expired or was revoked out-of-band;
// keep the row + error so the admin can see the app needs a re-mint.
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
}
} catch (e) {
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
}
}
}
// Start the loop (idempotent). unref() so an open handle never blocks exit.
function startAppTokenRenewal() {
if (renewTimer) return renewTimer;
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
renewTimer = setInterval(() => {
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
}, RENEW_INTERVAL_MS);
if (renewTimer.unref) renewTimer.unref();
return renewTimer;
}
// ── Grant / revoke shared-secret access ─────────────────────────────────────
// Creating a grant writes the DB row and then edits the grantee's policy content
// to add read on the shared path; revoking removes both. Because OpenBao parses
// policy content live, the change applies to the grantee's existing token
// immediately — no token re-mint, no cache invalidation needed.
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
const grant = await SharedSecretGrant.create({
secretId, granteeType, granteeId, capability: 'read',
created_by: actorUid, created_on: Date.now(),
updated_by: actorUid, updated_on: Date.now(),
});
await reconcileGrantee(granteeType, granteeId);
return grant;
}
async function revokeSharedSecret(grantId, actorUid) {
const grant = await SharedSecretGrant.get(grantId);
if (!grant) return null;
const { granteeType, granteeId } = grant;
await grant.delete();
await reconcileGrantee(granteeType, granteeId);
return grant;
}
// Recompute and rewrite a grantee's policy content after a grant/revoke.
async function reconcileGrantee(granteeType, granteeId) {
if (granteeType === 'user') {
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
} else if (granteeType === 'app') {
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
} else {
throw new Error(`invalid granteeType: ${granteeType}`);
}
}
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
// nothing). The guard mints a server-side token for the user (per-user or
@@ -190,17 +354,13 @@ async function scopeGuard(req, res, next) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
}
// Defense-in-depth: confirm the requested path is within the subject's
// namespace. Admins roam all of secret/; users are confined to
// secret/users/<uid>/. (The token's own policy enforces the same at the
// OpenBao layer; this catches a buggy/malicious client early with a clear
// 403 instead of an opaque OpenBao denial.)
const norm = normalizeVaultPath(req.path);
if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' });
}
const base = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/');
const userBase = `/secret/users/${uid}`;
const sharedBase = `/secret/shared`;
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' });
}
@@ -215,15 +375,20 @@ function vaultProxy() {
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/api/vault': '/v1' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
},
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
// which shipped this proxy with NO token injection — every /api/vault
// call reached OpenBao unauthenticated and 403'd.
onProxyReq(proxyReq, req, res, options) {
// Header ops MUST precede fixRequestBody: it write()s the parsed body
// onto proxyReq, which flushes headers — setHeader after that throws
// (swallowed upstream), silently dropping the token on every write.
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
fixRequestBody(proxyReq, req, res, options);
},
});
}
@@ -237,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
const result = await mintAppToken(name);
const result = await mintAppToken(name, req.user && req.user.uid);
res.json(result);
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
@@ -245,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
}
});
// List the minted external-app tokens (metadata only — the token itself is shown
// once at mint and never stored; the accessor is a renewal/revoke handle and is
// never exposed). Lets the Apps tab show what has been minted instead of a
// credential vanishing into the void.
mintAppRouter.get('/', async (req, res, next) => {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const rows = await VaultAppToken.list();
res.json({ apps: rows.map((r) => ({
name: r.name,
createdBy: r.created_by,
createdOn: r.created_on,
lastRenewedAt: r.lastRenewedAt || null,
lastError: r.lastError || null,
})) });
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
next(e);
}
});
module.exports = {
getOrCreateUserToken,
getOrCreateAdminToken,
@@ -253,4 +439,16 @@ module.exports = {
scopeGuard,
vaultProxy,
mintAppRouter,
};
// app-token lifecycle
renewAppTokens,
startAppTokenRenewal,
VaultAppToken,
// sharing
SharedSecret,
SharedSecretGrant,
userPolicyHcl,
appPolicyHcl,
grantSharedSecret,
revokeSharedSecret,
reconcileGrantee,
};
+69 -112
View File
@@ -1,4 +1,5 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
@@ -48,7 +49,7 @@
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
smtp: {
@@ -76,7 +77,7 @@
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
@@ -93,7 +94,7 @@
const btn = $('#btn-test-email');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
@@ -126,7 +127,7 @@
const btn = $('#btn-test-sms');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
@@ -174,7 +175,7 @@
async function saveProxyConf() {
const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
oidc: {
@@ -254,7 +255,7 @@
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-3"><i class="fas fa-plug text-secondary mb-2 fs-3"></i><br>No messaging plugins configured.</div>');
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
@@ -303,76 +304,52 @@
}
</script>
<div class="container-fluid py-4 px-md-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
<div>
<h3 class="fw-bold mb-1"><i class="fas fa-sliders-h text-primary me-2"></i> System Configuration</h3>
<p class="text-muted mb-0 small">
Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao.
</p>
</div>
<div>
<button class="btn btn-outline-secondary me-2" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-lg-3 col-md-4">
<div class="card shadow-sm border-0 sticky-top" style="top: var(--sw-content-offset, 70px);">
<div class="list-group list-group-flush rounded-3" id="conf-nav-list" role="tablist">
<a class="list-group-item list-group-item-action active d-flex align-items-center py-3" id="tab-nav-oauth" data-bs-toggle="list" href="#pane-oauth" role="tab">
<i class="fas fa-key text-success me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">OAuth & JWT</div>
<div class="small text-muted">Issuer & Token Lifetimes</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-smtp" data-bs-toggle="list" href="#pane-smtp" role="tab">
<i class="fas fa-envelope text-primary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Email (SMTP)</div>
<div class="small text-muted">Mail Delivery & Testing</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-sms" data-bs-toggle="list" href="#pane-sms" role="tab">
<i class="fas fa-comment-sms text-info me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">SMS & Messaging</div>
<div class="small text-muted">VoIP.ms & Webhook Plugins</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-proxy" data-bs-toggle="list" href="#pane-proxy" role="tab">
<i class="fas fa-shield-alt text-warning me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Proxy Secrets</div>
<div class="small text-muted">OpenBao Integration</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-tos" data-bs-toggle="list" href="#pane-tos" role="tab">
<i class="fas fa-file-contract text-secondary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Terms of Service</div>
<div class="small text-muted">User Agreement & Policy</div>
</div>
</a>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
</button>
</li>
</ul>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
</div>
</div>
<!-- Main Content Panes -->
<div class="col-lg-9 col-md-8">
<div class="tab-content" id="conf-tab-content">
<!-- OAuth Pane -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
</div>
<div class="card-body p-4">
<div class="card-body p-4">
<div class="tab-content" id="confTabContent">
<!-- OAuth & JWT Tab -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
@@ -396,16 +373,11 @@
</div>
</div>
</div>
</div>
</div>
<!-- SMTP Pane -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
</div>
<div class="card-body p-4">
<!-- SMTP Tab -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
@@ -449,16 +421,11 @@
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
</div>
</div>
<!-- SMS & Messaging Pane -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
</div>
<div class="card-body p-4">
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
@@ -495,16 +462,10 @@
</div>
<div id="messaging-plugins-list"></div>
</div>
</div>
</div>
<!-- Proxy Secrets Pane -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
</div>
<div class="card-body p-4">
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
@@ -537,17 +498,13 @@
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
</div>
</div>
<!-- Terms of Service Pane -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="card-body p-4">
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
@@ -559,9 +516,9 @@
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
+308 -14
View File
@@ -30,6 +30,7 @@
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
@@ -72,6 +73,7 @@
<tr id="resource-row-{{id}}">
<td class="ps-3">
{{{indentHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
@@ -201,7 +203,10 @@
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
@@ -232,7 +237,8 @@
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace" readonly>
<div class="form-text">Derived from the name; read-only.</div>
</div>
</div>
@@ -473,6 +479,7 @@
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
],
footer: {
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
@@ -518,24 +525,43 @@
}
});
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByToken = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
// host red as if it lacked an agent.
var agentsUnavailable = false;
async function loadResources() {
try {
const [resResources, resGroups, resEdges, resAccess] = await Promise.all([
const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges'),
// Access counts are a nicety, not load-bearing: if the LDAP join fails
// the table still renders, just without the Access column populated.
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; })
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
// Agents are a nicety too: never block the directory on them. Track
// whether the endpoint itself is reachable so host rows can tell "no
// agent on this host" from "agent service is down" (see attachAgentStatus).
app.api.get('agent/nodes')
.then(function(res){ agentsUnavailable = false; return res; })
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
]);
accessSummary = (resAccess && resAccess.results) || {};
resourcesById = {};
for (const r of resResources.results) {
r.metadata = r.metadata || {};
resourcesById[r.id] = r;
}
indexAgents((resAgents && resAgents.agents) || []);
allGroups = resGroups.results;
allEdges = resEdges.results;
@@ -569,6 +595,88 @@
}
}
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
function indexAgents(agents) {
agentsByHost = {};
agentsByToken = {};
for (const a of agents || []) {
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
if (hn) agentsByHost[hn] = a;
if (a.token) agentsByToken[a.token] = a;
}
}
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
// stores the agent on resourcesById so the Metrics tab can find it.
function attachAgentStatus(n) {
n.isHost = true;
const name = (n.name || '').toLowerCase();
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
}
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
const t = a.telemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754';
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
}
// Metrics tab body for the resource modal (snapshot of the joined agent).
function metricsTabHtml(agent) {
if (!agent) {
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
}
const d = agent.discovery || {};
const t = agent.telemetry || {};
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
return `<div class="p-3">
<div class="mb-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
</div>
<div class="row g-3">
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
<div class="col-6">GPU <strong>${gpu}</strong></div>
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
</div>
<hr><h6>Discovery</h6>
<div class="row small text-muted">
<div class="col-6">OS: ${esc(d.os || '')}</div>
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
<div class="col-6">Location: ${esc(d.location || '')}</div>
</div>
</div>`;
}
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() {
try {
const res = await app.api.get('agent/nodes');
indexAgents((res && res.agents) || []);
agentsUnavailable = false;
renderTable();
} catch (e) {
agentsUnavailable = true;
renderTable(); // re-render so dots flip to neutral, not stale green
}
}
// "Who can reach this?" at a glance. A resource with no linked group is not a
// locked-down resource -- it is an unreachable one, and a group whose LDAP
// entry has been deleted grants nothing, so both get called out rather than
@@ -676,6 +784,7 @@
}
n.indentHtml = indentHtml;
n.accessHtml = accessCellHtml(n.id);
if (n.kind === 'host') attachAgentStatus(n);
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
@@ -1259,9 +1368,10 @@
function renderDiscoveryTable() {
const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => {
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
const isManaged = !!(r.metadata && r.metadata.managed);
if(isManaged) return false;
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
return true;
});
@@ -1541,15 +1651,28 @@
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
// lastError/lastLog) but was dropped here; show it so a plugin that errors
// is visible without digging into logs.
const runOk = p.lastStatus === 'ok';
const runErr = p.lastStatus === 'error';
const runState = p.lastRunAt
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
: '<span class="text-muted">Never run</span>';
const logsBtn = (p.lastLog || p.lastError)
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
: '';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
<div class="small">Last run: ${runState}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
${logsBtn}
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
@@ -1561,6 +1684,30 @@
});
}
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
function fmtRunTs(ts) {
if (!ts) return 'Never run';
const m = moment(ts);
return m.isValid() ? m.fromNow() : 'Never run';
}
// Modal showing the discovery plugin's last run log + error (from the plugins
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
// <pre> rather than a toast.
function showPluginLog(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
const body = p.lastError
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
: '';
const log = p.lastLog || '(no log captured for this run)';
app.modal.open({
title: 'Run log — ' + (p.name || p.slug),
size: 'lg',
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
@@ -1582,21 +1729,168 @@
}
}
async function deleteDiscoveryPlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?');
if (!ok) return;
var discoveryPluginTypes = [];
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
// Stored value is always a 5-field cron string; the dropdown picks a preset
// and "Custom…" reveals the raw input. Config fields are driven by each
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
// tokenId / tokenSecret) are collected at create time.
var DP_CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function dpCronKeyFor(cron) {
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function dpCronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = dpCronKeyFor(current);
var opts = DP_CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="dpOnCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + current + '"' + rawStyle + '>';
}
function dpOnCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') { raw.style.display = ''; }
else {
raw.style.display = 'none';
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function dpCronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
function dpConfigFormHtml(type, prefix) {
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
var schema = t && t.configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
var html = '';
schema.forEach(function(f) {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
});
return html;
}
function dpCollectConfig(type, prefix) {
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
var schema = t && t.configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
return out;
}
function dpRenderFields() {
var type = document.getElementById('new-plugin-type').value;
document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-');
}
function openNewDiscoveryPluginModal() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
if (discoveryPluginTypes.length === 0) {
app.messages.toast('No discovery plugin types available', 'warning');
return;
}
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<select id="new-plugin-type" class="form-select shadow-sm" onchange="dpRenderFields()">${options}</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
<div class="form-text">A slug is derived automatically from the name.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Schedule</label>
${dpCronSelectHtml('np-', '0 * * * *')}
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
</div>
<hr><h6 class="fw-bold">Configuration</h6><div id="new-plugin-config-fields">${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
</div>
`;
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'lg'
});
});
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const cron = dpCronFromForm('np-');
const enabled = $('#new-plugin-enabled').is(':checked');
const config = dpCollectConfig(type, 'np-');
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Discovery plugin deleted', 'success');
await app.api.post('plugins', {
pluginType: type,
name,
cron,
enabled,
config
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
}
}
$(document).ready(function(){
loadDiscoveryResources();
loadDiscoveryPlugins();
// Keep the host status dots live: refresh the agent join periodically and on
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
refreshAgents();
setInterval(refreshAgents, 30000);
try {
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
dirAgentSocket.on('agent.telemetry', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
});
dirAgentSocket.on('agent.discovery', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
});
} catch (e) { /* socket is optional; periodic refresh still runs */ }
});
</script>
-406
View File
@@ -1,406 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
var userlist;
var allGroups = [];
// A member DN under the groups base is a nested group, not a person. Both
// live in the same `member` attribute, so they have to be told apart here --
// otherwise a nested group renders as a user whose name happens to be the
// group's, and its remove button calls the user endpoint and 404s.
function isGroupDn(dn){
return /,ou=groups,/i.test(String(dn));
}
function processGroup(value){
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
// Split before anything else consumes `member`.
value.nested = value.member.filter(isGroupDn).map(function(dn){
return {
dn: dn,
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
groupCN: value.cn
};
});
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
value.nestedCount = value.nested.length;
value.hasNested = value.nestedCount > 0;
// Candidates to nest: every other group not already nested here. Self is
// excluded; deeper loops are refused server-side by Group.wouldCycle,
// which is the only place that can see the whole graph.
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
value.toNest = allGroups.filter(function(g){
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
value.toAdd = userlist.filter(function(user){
return !value.member.includes(user.dn);
});
value.toAddOwner = userlist.filter(function(user){
return !value.owner.includes(user.dn);
});
value.member = value.member.map(function(user){
return {
dn: user,
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
};
});
value.owner = value.owner.map(function(user){
return {
dn: user,
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
};
});
value.memberCount = value.member.length;
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.groupCN = value.cn;
return value;
}
// app_sso_service_account is a marker group: membership hides an account
// from the Users page's People tab entirely (see users.ejs), which is
// exactly right for a non-person account but has silently made a real
// person's account look "gone" before (nothing else about it changes).
// Everywhere else in this dropdown just fires the PUT directly; only
// this one group gets a confirmation first.
function addMemberClick(event, groupCN, uid, el){
event.preventDefault();
const $el = $(el);
(async function(){
if (groupCN === 'app_sso_service_account') {
const ok = await app.messages.confirm(
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
$el.closest('.card'), 'warning'
);
if (!ok) return;
}
try {
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
await addedUser(data.message, groupCN, uid, $el);
} catch(e) {
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
}
})();
return false;
}
async function addedUser(message, group, user, $form){
let data = await app.group.get(group);
$.scope.groupCard.update('cn', group, processGroup(data.results));
app.messages.action(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
}
function applySort() {
const sort = $('#groupSort').val();
const scope = $.scope.groupCard;
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
}
function matchesSearch(g) {
const q = $('#groupSearch').val().toLowerCase().trim();
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
}
function applyFilters() {
applySort();
const groups = allGroups.filter(matchesSearch);
$.scope.groupCard.empty();
$.scope.groupCard.push(...groups);
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
}
async function tableAJAX(revealCn) {
let data = await app.group.list();
// processGroup builds each card's "nest a group" list from allGroups, so
// it has to see the full set before the map runs -- assigning only the
// mapped result would leave every dropdown empty on first load (and one
// render stale thereafter). The raw entries carry the cn/dn it needs.
allGroups = data.results;
allGroups = data.results.map(processGroup);
applyFilters();
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
}
function addNestedClick(event, groupCN, childCN, el){
event.preventDefault();
const $card = $('#group-card-' + groupCN);
(async function(){
try {
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $card, 'success');
} catch(e) {
// 409 here is the cycle guard or an already-nested group -- both
// carry a specific server message worth showing verbatim.
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
}
})();
}
async function removeNested(groupCN, childCN, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
$item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
}
}
async function removeMember(groupCN, uid, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
}
}
async function removeOwner(groupCN, uid, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
}
}
async function deleteGroup(cn, btn) {
const $card = $(btn).closest('.card');
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
if (!confirmed) return;
try {
await app.api.delete(`group/${cn}`);
$.scope.groupCard.remove('cn', cn);
} catch(e) {
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
}
}
app.auth.forceLogin(['app_sso_admin', 'admin']);
$(document).ready(async function(){
userlist = (await app.user.list()).results;
tableAJAX();
});
</script>
<div class="container mt-4">
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
<div class="input-group" style="flex: 1 1 200px;">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
</div>
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
<option value="name-asc">Name A → Z</option>
<option value="name-desc">Name Z → A</option>
<option value="members-desc">Most members</option>
<option value="members-asc">Fewest members</option>
</select>
<span id="groupCount" class="text-muted text-nowrap small"></span>
</div>
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
<div class="col">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-object-group"></i>
Add new group
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>
</div>
</div>
</div>
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
<div class="card shadow col">
<div class="card-header">
<h5>
<i class="fa-solid fa-arrows-down-to-people"></i>
Group: {{ cn }}
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</h5>
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
<i class="fa-solid fa-users"></i>
Members
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
<i class="fa-solid fa-layer-group"></i>
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
<i class="fa-solid fa-user-tie"></i>
Owners
</a>
</li>
<li class="nav-item float-end">
</li>
</ul>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p>
{{ description }}
</p>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
<p>
<ul class="list-group">
{{ #member }}
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
<i class="fa-solid fa-user"></i> {{ uid }}
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-user-slash"></i>
</button>
</li>
{{ /member }}
</ul>
</p>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
{{ #toAdd }}{{#.}}
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
<i class="fa-solid fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAdd }}
</div>
</div>
</div>
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
<p class="text-muted small mb-2">
Everyone in a nested group is a member of this one, at any depth.
</p>
<ul class="list-group">
{{ #nested }}
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
<i class="fa-solid fa-layer-group"></i> {{ cn }}
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-link-slash"></i>
</button>
</li>
{{ /nested }}
{{ ^hasNested }}
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
{{ /hasNested }}
</ul>
<div class="dropdown mt-2">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-diagram-project"></i> Nest a group
</button>
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
{{ #toNest }}
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
{{ /toNest }}
</div>
</div>
</div>
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
<p>
<ul class="list-group">
{{ #owner }}
<li class="list-group-item shadow">
<i class="fa-solid fa-user"></i> {{ uid }}
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-user-slash"></i>
</button>
</li>
{{ /owner }}
</ul>
</p>
<div class="dropdown float-start">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
{{ #toAddOwner }}{{#.}}
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
<i class="fa-solid fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAddOwner }}
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="float-end">
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
<i class="fa-solid fa-edit"></i>
</button>
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div>
Created: {{createTimestamp}}<br />
Last Modified: {{modifyTimestamp}}
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+2 -2
View File
@@ -657,8 +657,8 @@
</script>
<div id="own-api-tokens-section" style="display:none">
<div class="row mt-3 justify-content-center">
<div class="col-md-8">
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
+1 -1
View File
@@ -49,7 +49,7 @@
</ul>
<div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
+333 -22
View File
@@ -1,26 +1,32 @@
<%- include('top') %>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
<ul class="nav nav-pills" id="vault-tabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
</ul>
</div>
<div class="tab-content">
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content">
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-secrets">
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary" onclick="showCreateModal()">
<i class="fas fa-plus"></i> New Secret
</button>
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
<div class="d-flex align-items-center gap-2">
<a href="/docs/vault" class="text-reset" title="Vault help &amp; documentation"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
</div>
</div>
<div class="row">
<div class="p-3">
<div class="row">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="card-header"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="list-group list-group-flush" id="secrets-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
@@ -28,7 +34,7 @@
</div>
<div class="col-md-8">
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
@@ -44,17 +50,20 @@
<h4>Select a secret to view its details</h4>
</div>
</div>
</div>
</div>
</div>
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
<div class="tab-pane fade" id="tab-apps">
<div class="row">
<div class="p-3">
<div class="row">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-header"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-body">
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/&lt;name&gt;/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
<div class="mb-3">
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
@@ -66,7 +75,7 @@
</div>
<div class="col-md-7">
<div class="card shadow-sm d-none" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">App token</h5>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
</div>
@@ -81,8 +90,124 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/&lt;name&gt;/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="tab-shared">
<div class="p-3">
<div class="row">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">My shared secrets</h5>
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
</div>
<div class="list-group list-group-flush" id="shared-mine-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="card-title mb-0">Shared with me</h5></div>
<div class="list-group list-group-flush" id="shared-granted-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Shared Secret Modal -->
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Shared Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (slug)</label>
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
"key": "value"
}</textarea>
</div>
<div class="alert alert-danger d-none" id="shared-create-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
</div>
</div>
</div>
</div>
<!-- Manage Grants Modal -->
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Share</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
</div>
<div class="alert alert-danger d-none" id="grants-error"></div>
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- View Shared Secret Modal -->
<div class="modal fade" id="sharedViewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="shared-view-title">Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
</div>
</div>
</div>
@@ -134,7 +259,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`;
let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
}
function apiCall(method, path, body = null) {
@@ -156,7 +286,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
async function loadSecrets() {
try {
const res = await apiCall('GET', vpath('metadata', '?list=true'));
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
@@ -291,16 +422,194 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
// List the minted external-app tokens (metadata only). Makes the Apps tab show
// what's been minted instead of a credential that vanishes after the once-only
// token display.
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.textContent = 'Loading…';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
// ── Shared secrets tab ──────────────────────────────────────────────
let currentShared = null;
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
function sharedApi(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
if (body) opts.body = JSON.stringify(body);
return fetch('/api/shared-secrets' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
if (res.status === 204) return null;
return res.json();
});
}
async function loadShared() {
try {
const res = await sharedApi('/');
const items = (res && res.items) || [];
renderSharedMine(items.filter(i => i.role === 'owner'));
renderSharedGranted(items.filter(i => i.role === 'grantee'));
} catch (err) {
document.getElementById('shared-mine-list').innerHTML =
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
}
}
function renderSharedMine(items) {
const el = document.getElementById('shared-mine-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
</div>`;
el.appendChild(row);
});
}
function renderSharedGranted(items) {
const el = document.getElementById('shared-granted-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('a');
row.href = '#';
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
el.appendChild(row);
});
}
function showCreateSharedModal() {
currentShared = null;
document.getElementById('shared-slug-input').value = '';
document.getElementById('shared-desc-input').value = '';
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
document.getElementById('shared-create-error').classList.add('d-none');
sharedCreateModal.show();
}
async function saveSharedSecret() {
const err = document.getElementById('shared-create-error');
err.classList.add('d-none');
let data;
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
try {
await sharedApi('/', 'POST', {
slug: document.getElementById('shared-slug-input').value.trim(),
description: document.getElementById('shared-desc-input').value.trim(),
data
});
sharedCreateModal.hide();
await loadShared();
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function viewShared(s) {
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
document.getElementById('shared-view-content').textContent = 'Loading...';
sharedViewModal.show();
try {
const res = await apiCall('GET', 'secret/data/' + s.path);
document.getElementById('shared-view-content').textContent =
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
} catch (e) {
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
}
}
async function openGrants(id) {
currentShared = id;
document.getElementById('grants-error').classList.add('d-none');
document.getElementById('grant-id-input').value = '';
sharedGrantsModal.show();
try {
const res = await sharedApi('/' + id + '/grants');
const grants = (res && res.grants) || [];
const el = document.getElementById('grants-list');
el.innerHTML = '';
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
grants.forEach(g => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
el.appendChild(row);
});
} catch (e) {
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
}
}
async function addGrant() {
const err = document.getElementById('grants-error');
err.classList.add('d-none');
try {
await sharedApi('/' + currentShared + '/grants', 'POST', {
granteeType: document.getElementById('grant-type-input').value,
granteeId: document.getElementById('grant-id-input').value.trim()
});
document.getElementById('grant-id-input').value = '';
openGrants(currentShared);
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function revokeGrant(grantId) {
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
}
async function deleteShared(id) {
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
if (!confirmed) return;
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
}
(async function init() {
const user = await app.auth.forceLogin();
if (!user) return; // not logged in — forceLogin redirected to /login
@@ -312,8 +621,10 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
loadApps();
}
loadSecrets();
loadShared();
})();
</script>