Compare commits

...

21 Commits

Author SHA1 Message Date
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
28 changed files with 1588 additions and 539 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/
+33
View File
@@ -1,3 +1,36 @@
# 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)
+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
+16 -1
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_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
@@ -385,6 +389,17 @@ member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
# god_admin is the top of the lattice; nesting it into app_super_admin
# (which is itself nested into the app_sso_* groups above) makes it
# resolve to everything app_super_admin holds at the LDAP level too.
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
dn: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
EOF
info "Nested god_admin into app_super_admin"
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."
+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);
+34 -1
View File
@@ -19,6 +19,7 @@ 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',
@@ -33,16 +34,48 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant,
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;
+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.21.0",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.21.0",
"version": "1.26.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.21.0",
"version": "1.26.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;
+20 -1
View File
@@ -1,8 +1,12 @@
'use strict';
const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
@@ -71,8 +75,23 @@ module.exports = function initAgentWebSockets(app) {
} catch (e) {}
});
// 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({
+213 -39
View File
@@ -8,6 +8,7 @@ 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
@@ -29,6 +30,148 @@ 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;
}
}
// 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 ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {});
};
const sAdmin = groups.siteSuperAdminCns(siteSlug);
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
await link(sAdmin, true);
for (const kind of ['host', 'app']) {
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`);
await link(aggAdmin, true);
await link(aggAccess, false);
}
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).
// The specific group name uses the resource's slug verbatim
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
// `kind` (host/app) selects which aggregate the group nests into:
//
// {site}_{slug}_admin -> {site}_{slug}_access
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
// app_super_admin -> {site}_{slug}_admin (legacy cross-app)
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, '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 ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {});
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
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); // legacy cross-app 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();
if (resource.kind === 'site') {
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
for (const k of ['host', 'app']) {
valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, k, 'access'));
}
return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) };
}
const kind = groupKind(resource); // 'host'|'app'|null
if (kind) {
const slug = resource.slug; // verbatim (kind is carried in the slug)
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, slug, '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}_(${slug}_|${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 +193,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 +268,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,6 +417,28 @@ router.get('/groups', async (req, res, next) => {
router.post('/groups', async (req, res, next) => {
try {
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 ResourceGroup.create(req.body);
res.json({ results: g });
} 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){
+121
View File
@@ -0,0 +1,121 @@
'use strict';
const {
slugify,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
GOD_ADMIN,
} = require('../utils/groups');
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
// prefix, services/apps are stored bare. The group-model builders use these
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
const APP = { site: 'main-office', kind: 'app', 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 uses the resource slug verbatim (kind is carried in the slug)', () => {
expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_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, not re-slugified', () => {
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
// site (`site_local` -> `site-local`) would corrupt the delimiter.
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');
expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access');
});
test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => {
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);
// aggregate capability
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', 'emby', 'admin');
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
});
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);
});
});
+153
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(() => {
@@ -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' } });
});
});
+132
View File
@@ -0,0 +1,132 @@
'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)`);
}
// {site}_{slug}_{level} — the per-resource group for one resource.
//
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
// yields `site_local_host_theta-env_access`. Services are stored without a
// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the
// convention the auto-provisioner, the resolver, and the access-request tests
// all share -- re-slugifying or inserting a kind would double the delimiter.
function resourceGroupCns(site, slug, level) {
return `${site}_${slug}_${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` and `slug` are used verbatim (resource slugs may carry a kind prefix,
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
const site = resource && resource.site;
const kind = resource && resource.kind;
const slug = 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, slug, 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, slug, level))) return true;
return false;
}
module.exports = {
GOD_ADMIN,
KNOWN_LEVELS,
KINDS,
slugify,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
};
+62 -4
View File
@@ -1,10 +1,26 @@
'use strict';
const {Group} = require('../models/group_ldap');
const groups = require('./groups');
// The group nested into every resource's _admin group by api_directory_admin
// (cross-resource super-admin administration). KEEP the legacy `app_super_admin`
// here: it is the group that actually exists and gets nested. The new schema's
// global `god_admin` is recognized in isSuperAdmin() below, and api_directory_admin
// nests SUPER_ADMIN_GROUP -- so until `god_admin` is created during bootstrap, this
// must stay `app_super_admin` or resource auto-provisioning's nesting silently
// no-ops (leaving only the creator as the group's sole member).
const SUPER_ADMIN_GROUP = 'app_super_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 +33,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 +58,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>/*.
+109 -17
View File
@@ -32,6 +32,7 @@ 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)
@@ -83,15 +84,18 @@ async function ensurePolicy(name, hcl) {
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 ───────────────────────────────────────────────
@@ -185,15 +189,94 @@ ${granted}`.trim();
// a later compromise of an admin session cannot recover previously-minted app
// 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).
async function mintAppToken(name) {
//
// 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}`, await appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
// App tokens are long-lived credentials: mint via the sso-app role (768h
// period) so a renewal inside every 32-day window keeps them alive forever.
// Fall back to the broker's own 24h role on deployments whose setup.sh
// predates the sso-app role (re-running setup.sh creates it).
let minted;
try {
minted = await mintToken([`app-${name}`], 'sso-app');
} catch (e) {
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
minted = await mintToken([`app-${name}`]);
}
const { token, accessor, ttl } = minted;
// Replace the app's accessor row; revoke the superseded token (best-effort —
// it may already be expired) so re-minting never leaves a zombie credential.
try {
const existing = await VaultAppToken.getByName(name);
if (existing) {
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
await existing.delete();
}
if (accessor) {
await VaultAppToken.create({
name, accessor,
lastRenewedAt: Date.now(),
created_by: actorUid, created_on: Date.now(),
});
}
} catch (e) {
// Accessor bookkeeping must never block handing the token out; without a
// row the token simply isn't auto-renewed (it still lives one full period).
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
}
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── 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
@@ -292,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);
},
});
}
@@ -314,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' });
@@ -330,6 +418,10 @@ module.exports = {
scopeGuard,
vaultProxy,
mintAppRouter,
// app-token lifecycle
renewAppTokens,
startAppTokenRenewal,
VaultAppToken,
// sharing
SharedSecret,
SharedSecretGrant,
+208 -17
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}}
@@ -235,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>
@@ -476,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'); }}) : '',
@@ -521,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;
@@ -572,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
@@ -679,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);
@@ -1588,6 +1694,79 @@
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; }
@@ -1601,25 +1780,22 @@
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">${options}</select>
<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">Slug</label>
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Cron Schedule</label>
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
<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>
@@ -1629,7 +1805,7 @@
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'md'
size: 'lg'
});
});
}
@@ -1637,20 +1813,20 @@
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
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.post('plugins', {
pluginType: type,
name,
slug,
cron,
enabled,
config: {}
config
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
@@ -1663,6 +1839,21 @@
$(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 { %>
+36 -24
View File
@@ -1,27 +1,29 @@
<%- 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>
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</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>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
</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>
@@ -29,7 +31,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>
@@ -45,17 +47,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">
@@ -67,7 +72,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>
@@ -82,15 +87,17 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="tab-shared">
<div class="row">
<div class="p-3">
<div class="row">
<div class="col-md-6">
<div class="card shadow-sm">
<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">My shared secrets</h5>
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
</div>
@@ -101,7 +108,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Shared with me</h5></div>
<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>
@@ -109,6 +116,11 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>