diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d240ab..1bf7cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 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__admin/access/; 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) diff --git a/docs/agents.md b/docs/agents.md index d1f020a..c996ed4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 :443: i/o timeout` while +connecting to `wss:///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://` or `http://: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). + + diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..a00b674 --- /dev/null +++ b/docs/groups.md @@ -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). ``/`` = the resource slug. +`` = 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_` | site | Capability `` on **all hosts** at `S`. | +| `S_host__admin` | host | Admin on host ``. | +| `S_host__access` | host | Access to host ``. | +| `S_host__` | host | Capability `` on host ``. | +| `S_apps_admin` | site | Admin on **all apps** at `S`. | +| `S_apps_access` | site | Access to **all apps** at `S`. | +| `S_apps_` | site | Capability `` on **all apps** at `S`. | +| `S_app__admin` | app | Admin on app ``. | +| `S_app__access` | app | Access to app ``. | +| `S_app__` | app | Capability `` on 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. +- **``** — 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__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 `. + +### 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=))`, 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__*` / `{site}_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__admin`. This keeps everything +self-consistent: the SSO is "just another app." diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 7528f11..2775a58 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -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; diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 5351b96..968b0e9 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -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')}, diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 0f07641..5924fa8 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -59,12 +59,6 @@ router.get('/overview', function(req, res) { res.render('overview', {...values}); }); -// Connected theta-agent hosts + live telemetry (admin). Data from -// GET /api/agent/nodes; live updates via socket.io 'agent.*' events. -router.get('/agents', function(req, res) { - res.render('agents', {...values}); -}); - router.get('/admin', (req, res) => res.redirect(301, '/overview')); router.get('/notifications', (req, res) => res.redirect(301, '/overview')); router.get('/dashboard', (req, res) => res.redirect(301, '/overview')); @@ -194,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}); }); diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 62da372..bfa3586 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -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){ diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js new file mode 100644 index 0000000..3450f8a --- /dev/null +++ b/nodejs/tests/groups.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, + GOD_ADMIN, +} = require('../utils/groups'); + +const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' }; +const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; +const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: '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(''); + }); + test('never contains an underscore (the structural delimiter)', () => { + expect(slugify('a_b_c')).not.toContain('_'); + expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__'); + }); +}); + +describe('group cn builders', () => { + test('per-resource uses singular kind', () => { + expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin'); + expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access'); + }); + test('aggregate uses 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('invalid kind throws', () => { + expect(() => resourceGroupCns('s', 'service', 'x', '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', 'app', '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); + }); +}); diff --git a/nodejs/utils/groups.js b/nodejs/utils/groups.js new file mode 100644 index 0000000..090c9eb --- /dev/null +++ b/nodejs/utils/groups.js @@ -0,0 +1,123 @@ +'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_ admin/access/capability on ALL hosts at the site +// {site}_hosts_ +// {site}_host__ admin/access/capability on ONE host +// {site}_apps_ ... on ALL apps at the site +// {site}_app__ ... on ONE app +// {site}_everyone / everyone meta groups (implicit membership) +// +// `level` is 'admin', 'access', or an opaque ``. `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}_host__ / {site}_app__ +function resourceGroupCns(site, kind, slug, level) { + assertKind(kind); + return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`; +} + +// {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). +function aggregateGroupCns(site, kind, level) { + assertKind(kind); + return `${slugify(site)}_${kind}s_${level}`; +} + +// {site}_super_admin +function siteSuperAdminCns(site) { + return `${slugify(site)}_super_admin`; +} + +// {site}_everyone +function siteEveryoneCns(site) { + return `${slugify(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) { + const site = slugify(resource && resource.site); + const kind = resource && resource.kind; + const slug = slugify(resource && resource.slug); + const set = new Set(memberOf || []); + + if (set.has(GOD_ADMIN)) return true; + if (set.has(siteSuperAdminCns(site))) return true; + + if (isKnownLevel(level)) { + // admin / access + if (set.has(aggregateGroupCns(site, kind, level))) return true; + if (set.has(resourceGroupCns(site, kind, 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, kind, slug, level))) return true; + return false; +} + +module.exports = { + GOD_ADMIN, + KNOWN_LEVELS, + KINDS, + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, +}; diff --git a/nodejs/utils/permission.js b/nodejs/utils/permission.js index 15fc2e0..68e7d19 100644 --- a/nodejs/utils/permission.js +++ b/nodejs/utils/permission.js @@ -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, ...) +}; diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index d35af61..74eedee 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -41,12 +41,10 @@ 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//*. {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, - {href: '/agents', icon: 'fa-solid fa-microchip', label: 'Agents', groups: ['app_sso_admin', 'admin']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, ], }; diff --git a/nodejs/views/agents.ejs b/nodejs/views/agents.ejs deleted file mode 100644 index 2f8662d..0000000 --- a/nodejs/views/agents.ejs +++ /dev/null @@ -1,112 +0,0 @@ -<%- include('top') %> - -
-
-

Theta Agents (connected hosts)

- -
- -
-
Connected agents
-
- - - - - - - - - - - - - - - - - -
HostIPStatusCPURAMDiskZFSGPULast seen
Loading agents...
-
-
- -

- Live data from the theta-agent telemetry stream. An agent reports hostname/IP discovery and - CPU/RAM/disk/ZFS/GPU usage every ~60s over the WebSocket; "Online" means seen in the last 90s. -

-
- - - -<%- include('bottom') %> diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index dd329cb..094b4bc 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -30,6 +30,7 @@
Directory Management +
@@ -72,6 +73,7 @@ {{{indentHtml}}} + {{#isHost}}{{/isHost}} {{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}} {{#metadata.isProduction}}Prod{{/metadata.isProduction}} {{^metadata.isProduction}}Dev{{/metadata.isProduction}} @@ -235,7 +237,8 @@
- + +
Derived from the name; read-only.
@@ -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,34 @@ } }); + // 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 = {}; + 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. + app.api.get('agent/nodes').catch(function(){ 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 +586,79 @@ } } + // 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) { 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 '
No theta-agent connected

Install the agent on this host to see live metrics.

'; + } + const d = agent.discovery || {}; + const t = agent.telemetry || {}; + const bar = (val) => `
`; + const online = agent.isOnline ? 'Online' : 'Offline'; + const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A'; + return `
+
+
${esc(agent.hostname || 'unknown')} ${online}
+ Last seen ${timeAgo(agent.lastSeen)} +
+
+
CPU ${t.cpu_usage_percent ?? 0}%${bar(t.cpu_usage_percent)}
+
RAM ${t.ram_usage_percent ?? 0}%${bar(t.ram_usage_percent)}
+
Disk ${t.disk_usage_percent ?? 0}%${bar(t.disk_usage_percent)}
+
GPU ${gpu}
+
ZFS ${esc(t.zfs_health || 'N/A')}
+
+
Discovery
+
+
OS: ${esc(d.os || '')}
+
Kernel: ${esc(d.kernel || '')}
+
IPs: ${esc((d.ip_addresses || []).join(', '))}
+
Location: ${esc(d.location || '')}
+
+
`; + } + + // 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) || []); + renderTable(); + } catch (e) { /* non-fatal */ } + } + // "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 +766,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 +1676,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 ''; + }).join(''); + var rawStyle = key === 'custom' ? '' : ' style="display:none"'; + return '' + + ''; + } + 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 '

No configuration fields for this plugin.

'; + 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 ? ' ' : '') + (f.required ? ' *' : ''); + html += '
' + + '
'; + }); + 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 +1762,22 @@ const bodyHtml = `
- +
+
A slug is derived automatically from the name.
- - -
-
- - -
Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)
+ + ${dpCronSelectHtml('np-', '0 * * * *')}
+
Configuration
${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}
@@ -1629,7 +1787,7 @@ app.modal.open({ title: 'Configure New Discovery Plugin', bodyHtml: bodyHtml, - size: 'md' + size: 'lg' }); }); } @@ -1637,20 +1795,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 +1821,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 */ } }); diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs deleted file mode 100644 index b16de27..0000000 --- a/nodejs/views/groups.ejs +++ /dev/null @@ -1,406 +0,0 @@ -<%- include('top') %> - - -
- -
-
- - -
- - -
-
-
-
-
- - Add new group - -
- -
-
-
- - -
- -
- - -
- - -
-
-
-
-
-
- - -
-

- {{ description }} -

-
-
-

-

    - {{ #member }} -
  • - {{ uid }} - -
  • - {{ /member }} -
-

- -
- -
-

- Everyone in a nested group is a member of this one, at any depth. -

-
    - {{ #nested }} -
  • - {{ cn }} - -
  • - {{ /nested }} - {{ ^hasNested }} -
  • No groups nested here.
  • - {{ /hasNested }} -
- -
- -
-

-

    - {{ #owner }} -
  • - {{ uid }} - -
  • - {{ /owner }} -
-

- - - -
-
-
- -
-
-
- -
-<%- include('bottom') %> diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 0ea839c..aaeb960 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -49,7 +49,7 @@
<% if(ui.profileUrl){ %> - <% } else { %> diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index aa9a7e4..1e0e5ad 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -1,27 +1,29 @@ <%- include('top') %> -
-
-

My Secrets (personal namespace)

- -
- -
+
+
+
+
+
+ +
+
+
-
- +
+
My Secrets (personal namespace)
+
-
+
+
-
Secrets List
+
Secrets List
Loading...
@@ -29,7 +31,7 @@
-
+
+
-
Mint an app token
+
Mint an app token

Mints a scoped OpenBao token confined to secret/apps/<name>/* for an external app. The token is shown once — record it in the app immediately; it cannot be recovered later.

The token is periodic: it stays valid as long as the app renews it within its period (POST /v1/auth/token/renew-self). If it lapses, mint a new one here — the app's policy and stored secrets are kept.

@@ -68,7 +72,7 @@
-
+
App token
@@ -83,15 +87,17 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+
-
+
+
-
+
My shared secrets
@@ -102,7 +108,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
-
Shared with me
+
Shared with me
Loading...
@@ -110,6 +116,11 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+
+
+
+
+