Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b42b3d8a | |||
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 |
@@ -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/
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
# v1.27.0
|
||||
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
|
||||
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
|
||||
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
|
||||
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
|
||||
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
|
||||
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
|
||||
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
|
||||
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
|
||||
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
|
||||
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
|
||||
|
||||
# v1.26.1
|
||||
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
||||
|
||||
# v1.26.0
|
||||
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
|
||||
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
|
||||
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
|
||||
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
|
||||
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
|
||||
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
|
||||
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
|
||||
|
||||
# v1.25.0
|
||||
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
||||
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
||||
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
|
||||
|
||||
# v1.24.0
|
||||
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
|
||||
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
|
||||
- feat: Directory resource slug is now read-only and derived from the name
|
||||
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
|
||||
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
|
||||
|
||||
# v1.23.0
|
||||
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
|
||||
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
|
||||
|
||||
@@ -184,6 +184,11 @@ COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||
COPY docs /docs
|
||||
|
||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
+12
-8
@@ -355,7 +355,11 @@ EOF
|
||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||
# app_sso_service_account is a marker (not a permission gate) for
|
||||
# non-person accounts -- see the Users page.
|
||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
#
|
||||
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||
# the theta-suite bootstrap puts the first admin person into it.
|
||||
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
objectClass: groupOfNames
|
||||
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
|
||||
EOF
|
||||
done
|
||||
|
||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
||||
# hold those rights by membership rather than by a special case in app code.
|
||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
||||
# anything binding LDAP directly -- instead of only to callers that happen
|
||||
# to route through utils/permission.js.
|
||||
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||
# by membership rather than by a special case in app code. This is what makes
|
||||
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||
# LDAP directly -- instead of only to callers that happen to route through
|
||||
# utils/permission.js.
|
||||
#
|
||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||
# non-person accounts, not a permission, and nesting admins into it would
|
||||
@@ -381,10 +385,10 @@ EOF
|
||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
||||
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||
EOF
|
||||
done
|
||||
info "Nested app_super_admin into the SSO admin groups"
|
||||
info "Nested god_admin into the SSO admin groups"
|
||||
fi
|
||||
|
||||
info "LDAP directory initialized"
|
||||
|
||||
@@ -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
@@ -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."
|
||||
@@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar.
|
||||
|
||||
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||
|
||||
## Apps tab (admin)
|
||||
|
||||
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||
|
||||
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||
|
||||
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||
|
||||
## Shared tab
|
||||
|
||||
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||
|
||||
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
|
||||
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
|
||||
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
|
||||
|
||||
## API Access
|
||||
|
||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||
|
||||
Generated
+18
-18
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.27.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
@@ -2344,9 +2344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -4294,9 +4294,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -5966,16 +5966,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
@@ -7726,9 +7726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7918,9 +7918,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.27.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,12 +8,11 @@ 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.");
|
||||
return;
|
||||
}
|
||||
|
||||
app.wss.on('connection', (ws, req) => {
|
||||
// Only the WebSocket handler needs the WS server. The REST routes mounted
|
||||
// below (/api/agent/*) must work regardless of the WS server state -- gating
|
||||
// them on `app.wss` made them 404 whenever it wasn't initialized.
|
||||
if (app.wss) {
|
||||
app.wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||
|
||||
@@ -74,6 +73,7 @@ module.exports = function initAgentWebSockets(app) {
|
||||
}));
|
||||
} catch (e) {}
|
||||
});
|
||||
} // end if (app.wss)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
|
||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||
const groups = require('../utils/groups');
|
||||
|
||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
||||
// the goal state, and a missing group (e.g. god_admin absent on a
|
||||
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||
// caller's real work.
|
||||
async function nestGroup(childCn, parentCn) {
|
||||
@@ -29,6 +30,160 @@ async function nestGroup(childCn, parentCn) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||
// The directory is the single place groups are created, as a projection of the
|
||||
// resource graph. These helpers materialize the group-inheritance lattice for
|
||||
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
|
||||
// All of them are idempotent, so calling them again for a resource a newer
|
||||
// release is backfilling is a no-op.
|
||||
|
||||
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
|
||||
// host -> host; service -> app (services/consoles are the group model's "apps");
|
||||
// site gets site-level groups (handled separately); oauth/container get no
|
||||
// per-resource groups (oauth clients hang off their owning service).
|
||||
function groupKind(resource) {
|
||||
if (resource.kind === 'host') return 'host';
|
||||
if (resource.kind === 'service') return 'app';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
|
||||
// seeds the mandatory first member. Returns true when created.
|
||||
async function ensureGroup(name, ownerDn, description) {
|
||||
try {
|
||||
await Group.add({ name, owner: ownerDn, description });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||
console.error(`ensureGroup: failed to create ${name}:`, err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Link a group to a resource only if that link doesn't already exist. The
|
||||
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
|
||||
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||
// "groups appear 3x under a resource" bug. Always check first.
|
||||
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||
if (existing.length) return existing[0];
|
||||
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||
}
|
||||
|
||||
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||
// older release gets its groups without a rebuild:
|
||||
//
|
||||
// god_admin -> {site}_super_admin
|
||||
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
|
||||
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
|
||||
//
|
||||
// `{site}_everyone` is created for completeness; it has implicit membership and
|
||||
// is granted to a resource as a grantee, never enumerated.
|
||||
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
||||
if (!siteSlug) return;
|
||||
|
||||
// Link a site group to the site resource (so it shows + is member-manageable
|
||||
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
|
||||
// groups as member.
|
||||
const link = async (cn, isAdmin) => {
|
||||
if (!siteResourceId) return;
|
||||
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
|
||||
};
|
||||
|
||||
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||
await link(sAdmin, true);
|
||||
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
|
||||
// them), but are NOT linked to the site resource: a site carries only the god
|
||||
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
|
||||
// aggregates have no modal home; site-wide access is granted via S_super_admin
|
||||
// and per-resource access via the host/app groups.
|
||||
for (const kind of ['host', 'app']) {
|
||||
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||
}
|
||||
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||
// god_admin is the global group; surface it on the site modal so its members
|
||||
// can be managed from the Directory (it has no home on a single resource).
|
||||
await link(groups.GOD_ADMIN, true);
|
||||
|
||||
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
|
||||
// binding directly) resolve it transitively, not just utils/permission.js.
|
||||
// nestGroup(child, parent) makes child a member of parent -- membership flows
|
||||
// child -> parent ("up"), so a group's members inherit what its parents hold.
|
||||
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
|
||||
for (const kind of ['host', 'app']) {
|
||||
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
|
||||
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
|
||||
}
|
||||
}
|
||||
|
||||
// Provision the per-resource groups for a host/app and nest them into the site
|
||||
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
|
||||
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
|
||||
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
|
||||
//
|
||||
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
|
||||
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
|
||||
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
|
||||
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
|
||||
|
||||
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||
|
||||
// Link both groups to the resource so the Directory can show/revoke them.
|
||||
await ensureResourceGroup(resource.id, accessCn, 'member');
|
||||
await ensureResourceGroup(resource.id, adminCn, 'owner');
|
||||
|
||||
await nestGroup(adminCn, accessCn); // administering implies using
|
||||
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
|
||||
}
|
||||
|
||||
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||
// §2/§3). This is what "force the correct naming convention" means: a group
|
||||
// linked to a resource must be one that parses for consumers -- the resource's
|
||||
// own specific groups, its site's aggregates, site-level groups, or the global
|
||||
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
|
||||
// capability groups following the same shapes.
|
||||
function validGroupCnsForResource(resource, siteSlug) {
|
||||
const valid = new Set();
|
||||
// A site resource only carries god_admin (added by the route) + the site-wide
|
||||
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
|
||||
// specific groups belong to host/app resources, not to the site.
|
||||
if (resource.kind === 'site') {
|
||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
|
||||
}
|
||||
const kind = groupKind(resource); // 'host'|'app'|null
|
||||
if (kind) {
|
||||
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
|
||||
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
|
||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
|
||||
}
|
||||
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||
return { valid, capRe: null };
|
||||
}
|
||||
|
||||
// Require the admin group
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
@@ -50,6 +205,36 @@ router.get('/resources', async (req, res, next) => {
|
||||
});
|
||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||
// the wire; projectResources strips it unconditionally.
|
||||
|
||||
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||
// aggregates, and every host/app resource has its per-resource groups nested
|
||||
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||
// backfills a directory seeded by an older release without a rebuild.
|
||||
// Never fails the list.
|
||||
const sites = resources.filter(r => r.kind === 'site');
|
||||
await Promise.all(sites.map(site =>
|
||||
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||
));
|
||||
const siteByResource = new Map();
|
||||
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||
const siteOf = async (r) => {
|
||||
const direct = siteByResource.get(r.id);
|
||||
if (direct) return direct;
|
||||
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||
};
|
||||
await Promise.all(resources.map(async (r) => {
|
||||
const gKind = groupKind(r);
|
||||
if (!gKind) return;
|
||||
const siteSlug = await siteOf(r);
|
||||
if (!siteSlug) return;
|
||||
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||
}));
|
||||
|
||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -95,46 +280,25 @@ router.post('/resources', async (req, res, next) => {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
|
||||
if (r.kind === 'host' || r.kind === 'service') {
|
||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
||||
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = groupCn(suffix);
|
||||
try {
|
||||
await Group.add({
|
||||
name: cn,
|
||||
owner: req.user.dn,
|
||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
||||
} catch(err) { /* ignore duplicate links */ }
|
||||
};
|
||||
await createGroup('access', 'member');
|
||||
await createGroup('admin', 'owner');
|
||||
|
||||
// Wire up the two standing relationships every resource has, as nesting
|
||||
// rather than as membership that has to be maintained per resource:
|
||||
//
|
||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
||||
// every resource, automatically
|
||||
// <slug>_admin -> <slug>_access administering something implies
|
||||
// being able to use it
|
||||
//
|
||||
// Before nesting, both of these could only be expressed by adding every
|
||||
// super admin to every new group by hand -- which nobody does, so the
|
||||
// groups drifted. A failure here must not fail resource creation: the
|
||||
// resource and its groups already exist and the nesting is repairable.
|
||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
||||
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||
// Materialize the group-model for the new resource. Site resources get the
|
||||
// site-level groups; host/app resources get their per-resource groups nested
|
||||
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||
// older release. A provisioning failure must not fail resource creation: the
|
||||
// resource already exists and the groups are repairable (re-run ensures them).
|
||||
//
|
||||
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||
// the kind prefix themselves.
|
||||
const gKind = groupKind(r);
|
||||
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||
if (r.kind === 'site') {
|
||||
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||
} else if (gKind && ancestorSite) {
|
||||
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||
}
|
||||
|
||||
|
||||
res.json({ results: r });
|
||||
} catch (err) {
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
@@ -265,7 +429,29 @@ router.get('/groups', async (req, res, next) => {
|
||||
|
||||
router.post('/groups', async (req, res, next) => {
|
||||
try {
|
||||
const g = await ResourceGroup.create(req.body);
|
||||
const { resourceId, groupCn } = req.body;
|
||||
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
|
||||
|
||||
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
|
||||
// be a valid group for this resource; reject free-form names so the groups
|
||||
// consumers read are always parseable. god_admin is always allowed (it is
|
||||
// the global group and is managed from a site's modal).
|
||||
const resource = await Resource.get(resourceId);
|
||||
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
|
||||
// site resource's own slug is its site; a host/app uses its ancestor site.
|
||||
const siteSlug = resource && resource.kind === 'site'
|
||||
? resource.slug
|
||||
: await Resource.findAncestorSiteSlug(resourceId);
|
||||
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
|
||||
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
|
||||
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
|
||||
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
|
||||
res.json({ results: g });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -313,7 +499,7 @@ router.get('/access-summary', async (req, res, next) => {
|
||||
//
|
||||
// Counts come from the transitive closure, not from `member`. Reading the
|
||||
// attribute would report only who is listed on the group, missing anyone
|
||||
// who reaches it through a nested group -- and since app_super_admin is
|
||||
// who reaches it through a nested group -- and since god_admin is
|
||||
// nested into every resource's _admin group, that is not an edge case.
|
||||
let members = [];
|
||||
if (group) {
|
||||
|
||||
@@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||
const vaultBroker = require('../utils/vault_broker');
|
||||
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
|
||||
// only reject values that can't be a sane secret path segment (spaces, slashes,
|
||||
// leading non-alnum, too long).
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => {
|
||||
if (byId.has(g.id)) continue; // already owner
|
||||
byId.set(g.id, { role: 'grantee', ...g });
|
||||
}
|
||||
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
|
||||
// The `{ role, ...s }` spread above copies only own properties, so the
|
||||
// instance method `path()` is dropped -- call the static builder instead.
|
||||
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const uid = req.user.uid;
|
||||
const slug = String(req.body.slug || '').trim().toLowerCase();
|
||||
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
|
||||
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
|
||||
const description = String(req.body.description || '').trim();
|
||||
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||
|
||||
|
||||
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
|
||||
|
||||
const meta = resource.metadata || {};
|
||||
meta.managed = true;
|
||||
await Resource.update(resource.id, { metadata: meta });
|
||||
|
||||
// `Resource.update` is not a static — `update` is an instance method
|
||||
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
|
||||
const inst = await Resource.get(resource.id);
|
||||
await inst.update({ metadata: meta });
|
||||
|
||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -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/');
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -44,9 +44,10 @@ beforeAll(async () => {
|
||||
expect(host.status).toBe(200);
|
||||
hostId = host.body.results.id;
|
||||
|
||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
||||
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||
|
||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
|
||||
expect(host.status).toBe(200);
|
||||
|
||||
// Same as the top-level setup: step out of the auto-created groups the
|
||||
// creator is seeded into, or this is a request for access already held.
|
||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
||||
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||
// or this is a request for access already held.
|
||||
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||
await request(app)
|
||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||
.set('auth-token', token);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
slugify,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
GOD_ADMIN,
|
||||
} = require('../utils/groups');
|
||||
|
||||
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
|
||||
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
|
||||
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
|
||||
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
|
||||
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
|
||||
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||
|
||||
describe('slugify', () => {
|
||||
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||
expect(slugify('Web 01')).toBe('web-01');
|
||||
expect(slugify('Main Office')).toBe('main-office');
|
||||
expect(slugify('my_host')).toBe('my-host');
|
||||
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group cn builders', () => {
|
||||
test('per-resource names the kind + name slug (docs §2)', () => {
|
||||
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||
});
|
||||
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
|
||||
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
|
||||
});
|
||||
test('aggregate uses the plural kind', () => {
|
||||
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||
});
|
||||
test('site super admin + everyone', () => {
|
||||
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||
});
|
||||
test('a directory site slug with a kind prefix is kept verbatim', () => {
|
||||
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||
});
|
||||
test('invalid kind throws', () => {
|
||||
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('levels', () => {
|
||||
test('admin/access known; capabilities opaque', () => {
|
||||
expect(isKnownLevel('admin')).toBe(true);
|
||||
expect(isKnownLevel('access')).toBe(true);
|
||||
expect(isKnownLevel('reboot')).toBe(false);
|
||||
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||
});
|
||||
test('admin implies access; access does not imply admin', () => {
|
||||
expect(levelGrants('admin', 'access')).toBe(true);
|
||||
expect(levelGrants('access', 'admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermission — inheritance', () => {
|
||||
test('god_admin grants everything everywhere', () => {
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||
});
|
||||
|
||||
test('site super admin grants everything on its site, not other sites', () => {
|
||||
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||
});
|
||||
|
||||
test('specific host group grants only that host', () => {
|
||||
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('admin implies access; access does not imply admin', () => {
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('capabilities are exact — admin does not grant a capability', () => {
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||
});
|
||||
|
||||
test('hosts and apps are orthogonal namespaces', () => {
|
||||
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||
});
|
||||
|
||||
test('a service maps to the app kind (docs §11)', () => {
|
||||
// The directory `service` kind is the group model's `app`.
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('cross-site isolation', () => {
|
||||
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
// Theta42 group & permission model.
|
||||
//
|
||||
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||
// uses the singular (host/app).
|
||||
//
|
||||
// god_admin global — everything, everywhere
|
||||
// {site}_super_admin everything on the site
|
||||
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||
// {site}_hosts_<level>
|
||||
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||
// {site}_apps_<level> ... on ALL apps at the site
|
||||
// {site}_app_<slug>_<level> ... on ONE app
|
||||
// {site}_everyone / everyone meta groups (implicit membership)
|
||||
//
|
||||
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||
//
|
||||
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||
|
||||
const GOD_ADMIN = 'god_admin';
|
||||
const KNOWN_LEVELS = ['admin', 'access'];
|
||||
const KINDS = ['host', 'app'];
|
||||
|
||||
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Validate a kind (host/app) — throw on anything else.
|
||||
function assertKind(kind) {
|
||||
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||
}
|
||||
|
||||
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
|
||||
// `theta-env`), leaving the resource's name slug. Services are stored bare
|
||||
// (`sso-manager`), so this is a no-op for them.
|
||||
function resourceNameSlug(slug) {
|
||||
return String(slug || '').replace(/^(site|host|app)_/, '');
|
||||
}
|
||||
|
||||
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
|
||||
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
|
||||
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
|
||||
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
|
||||
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
|
||||
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
|
||||
// `site_local_app_sso-manager_access`.
|
||||
function resourceGroupCns(site, kind, nameSlug, level) {
|
||||
assertKind(kind);
|
||||
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
|
||||
}
|
||||
|
||||
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||
function aggregateGroupCns(site, kind, level) {
|
||||
assertKind(kind);
|
||||
return `${site}_${kind}s_${level}`;
|
||||
}
|
||||
|
||||
// {site}_super_admin
|
||||
function siteSuperAdminCns(site) {
|
||||
return `${site}_super_admin`;
|
||||
}
|
||||
|
||||
// {site}_everyone
|
||||
function siteEveryoneCns(site) {
|
||||
return `${site}_everyone`;
|
||||
}
|
||||
|
||||
// True if `level` is a known admin/access level (not an opaque capability).
|
||||
function isKnownLevel(level) {
|
||||
return KNOWN_LEVELS.includes(level);
|
||||
}
|
||||
|
||||
// True if holding `level` grants `wanted` (admin implies access).
|
||||
function levelGrants(level, wanted) {
|
||||
if (level === wanted) return true;
|
||||
return level === 'admin' && wanted === 'access';
|
||||
}
|
||||
|
||||
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||
// `level` on a resource. Applies the inheritance lattice:
|
||||
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||
//
|
||||
// memberOf: array of group cns the user is a member of.
|
||||
// resource: { site, kind: 'host'|'app', slug }.
|
||||
// level: 'admin' | 'access' | an opaque capability token.
|
||||
//
|
||||
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||
// are resource-level grants, resolved by the caller against the resource's own
|
||||
// granted groups (see permission.onResource). This keeps the function pure over
|
||||
// the user's membership only.
|
||||
function hasPermission(memberOf, resource, level) {
|
||||
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
|
||||
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
|
||||
// apps); `nameSlug` is the resource name with any kind prefix stripped.
|
||||
const site = resource && resource.site;
|
||||
const rawKind = resource && resource.kind;
|
||||
const kind = rawKind === 'service' ? 'app' : rawKind;
|
||||
const nameSlug = resourceNameSlug(resource && resource.slug);
|
||||
const set = new Set(memberOf || []);
|
||||
|
||||
if (set.has(GOD_ADMIN)) return true;
|
||||
if (set.has(siteSuperAdminCns(site))) return true;
|
||||
|
||||
if (isKnownLevel(level)) {
|
||||
// admin / access
|
||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||
return false;
|
||||
}
|
||||
// Opaque capability — exact aggregate or specific grant only.
|
||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GOD_ADMIN,
|
||||
KNOWN_LEVELS,
|
||||
KINDS,
|
||||
slugify,
|
||||
resourceNameSlug,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
};
|
||||
@@ -1,10 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
const {Group} = require('../models/group_ldap');
|
||||
const groups = require('./groups');
|
||||
|
||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||
// The group nested into every resource's _admin group by api_directory_admin
|
||||
// (cross-resource super-admin administration). This is `god_admin` -- the global
|
||||
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
|
||||
// It used to be the legacy `app_super_admin`, which existed while god_admin
|
||||
// didn't; now that god_admin is created at boot, the provisioning nests it.
|
||||
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
|
||||
// the migration, so an existing deployment isn't stripped of rights until it's
|
||||
// rebuilt.
|
||||
const SUPER_ADMIN_GROUP = 'god_admin';
|
||||
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||
|
||||
let byGroup = async function(user, groups, ownerOf){
|
||||
// True if the user (by resolved member cns) is a global god/super admin.
|
||||
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
|
||||
async function isSuperAdmin(memberOfCns) {
|
||||
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||
}
|
||||
|
||||
let byGroup = async function(user, checkGroups, ownerOf){
|
||||
// Membership is resolved once, transitively: a user placed in an admin group
|
||||
// through a nested group is as much a member as one listed on it directly.
|
||||
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||
@@ -17,9 +34,9 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
// they still catch direct membership if the resolver is unavailable.
|
||||
}
|
||||
|
||||
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||
if(await isSuperAdmin(memberOfCns)) return true;
|
||||
|
||||
for(let group of groups){
|
||||
for(let group of checkGroups){
|
||||
if(memberOfCns.includes(group)) return true;
|
||||
}
|
||||
|
||||
@@ -42,4 +59,46 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||
// Resolve whether a user has `level` on a directory resource under the group
|
||||
// model (see utils/groups.js). Applies the inheritance lattice and the
|
||||
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
|
||||
//
|
||||
// user: the auth user ({ dn, isMachine }).
|
||||
// resource:{ site, kind: 'host'|'app', slug }.
|
||||
// level: 'admin' | 'access' | an opaque capability token.
|
||||
// grantedGroups: optional array of the resource's granted group cns (used only
|
||||
// for meta `everyone` handling). Omit to skip meta grants.
|
||||
async function onResource(user, resource, level, grantedGroups) {
|
||||
let memberOfCns = [];
|
||||
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
|
||||
|
||||
if (await isSuperAdmin(memberOfCns)) return true;
|
||||
if (groups.hasPermission(memberOfCns, resource, level)) return true;
|
||||
|
||||
// Meta grants: `everyone` / `{site}_everyone` confer access to any
|
||||
// authenticated (non-machine) user when the resource grants them.
|
||||
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
|
||||
const siteEveryone = groups.siteEveryoneCns(resource.site);
|
||||
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Like onResource but throws Insufficient Permission when denied — for guards.
|
||||
async function requireResource(user, resource, level, grantedGroups) {
|
||||
if (await onResource(user, resource, level, grantedGroups)) return;
|
||||
const error = new Error('Insufficient Permission');
|
||||
error.name = 'Insufficient Permission';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
byGroup,
|
||||
onResource,
|
||||
requireResource,
|
||||
isSuperAdmin,
|
||||
SUPER_ADMIN_GROUP,
|
||||
LEGACY_SUPER_ADMIN_ALIASES,
|
||||
...groups, // group schema builders (slugify, resourceGroupCns, ...)
|
||||
};
|
||||
|
||||
@@ -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/<uid>/*.
|
||||
{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']},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -410,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// List the minted external-app tokens (metadata only — the token itself is shown
|
||||
// once at mint and never stored; the accessor is a renewal/revoke handle and is
|
||||
// never exposed). Lets the Apps tab show what has been minted instead of a
|
||||
// credential vanishing into the void.
|
||||
mintAppRouter.get('/', async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
const rows = await VaultAppToken.list();
|
||||
res.json({ apps: rows.map((r) => ({
|
||||
name: r.name,
|
||||
createdBy: r.created_by,
|
||||
createdOn: r.created_on,
|
||||
lastRenewedAt: r.lastRenewedAt || null,
|
||||
lastError: r.lastError || null,
|
||||
})) });
|
||||
} catch (e) {
|
||||
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getOrCreateUserToken,
|
||||
getOrCreateAdminToken,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="fa-solid fa-microchip"></i> Theta Agents <small class="text-muted">(connected hosts)</small></h2>
|
||||
<button class="btn btn-outline-primary" onclick="loadAgents()"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Connected agents</h5></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
<th style="width:110px">CPU</th>
|
||||
<th style="width:110px">RAM</th>
|
||||
<th style="width:110px">Disk</th>
|
||||
<th>ZFS</th>
|
||||
<th>GPU</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agents-tbody">
|
||||
<tr><td colspan="9" class="text-center text-muted">Loading agents...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-3">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
let agentsById = {}; // token -> agent record
|
||||
|
||||
function bar(val) {
|
||||
val = Math.max(0, Math.min(100, val || 0));
|
||||
return `<div class="progress" style="height:8px"><div class="progress-bar" role="progressbar" style="width:${val}%"></div></div>`;
|
||||
}
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const m = moment(iso);
|
||||
return m.isValid() ? m.fromNow() : '';
|
||||
}
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return app.util.escapeHtml(String(s));
|
||||
}
|
||||
|
||||
function renderRow(id, a) {
|
||||
const d = a.discovery || {};
|
||||
const t = a.telemetry || {};
|
||||
const online = !!a.isOnline;
|
||||
const badge = `<span class="badge ${online ? 'bg-success' : 'bg-secondary'}">${online ? 'Online' : 'Offline'}</span>`;
|
||||
const cpu = t.cpu_usage_percent != null ? t.cpu_usage_percent : 0;
|
||||
const ram = t.ram_usage_percent != null ? t.ram_usage_percent : 0;
|
||||
const disk = t.disk_usage_percent != null ? t.disk_usage_percent : 0;
|
||||
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
|
||||
return `<tr id="agent-${id}">
|
||||
<td><strong>${esc(a.hostname || 'unknown')}</strong>${d.location ? `<div class="small text-muted">${esc(d.location)}</div>` : ''}</td>
|
||||
<td>${esc(a.ipAddress || '')}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${cpu}% ${bar(cpu)}</td>
|
||||
<td>${ram}% ${bar(ram)}</td>
|
||||
<td>${disk}% ${bar(disk)}</td>
|
||||
<td>${esc(t.zfs_health || 'N/A')}</td>
|
||||
<td>${gpu}</td>
|
||||
<td class="small text-muted">${timeAgo(a.lastSeen)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const tbody = document.getElementById('agents-tbody');
|
||||
try {
|
||||
const res = await app.api.get('agent/nodes');
|
||||
const agents = (res && res.agents) || [];
|
||||
agentsById = {};
|
||||
agents.forEach(a => { agentsById[a.token] = a; });
|
||||
tbody.innerHTML = agents.length
|
||||
? agents.map(a => renderRow(a.token, a)).join('')
|
||||
: '<tr><td colspan="9" class="text-center text-muted">No agents connected.</td></tr>';
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error loading agents: ${esc(err && err.message || err)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Live updates from the server's agent.* socket.io broadcasts. The app's
|
||||
// default socket is scoped to P2PSub, so open a dedicated socket here.
|
||||
const agentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||
agentSocket.on('agent.telemetry', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.telemetry = msg.payload; a.isOnline = true; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
agentSocket.on('agent.discovery', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.discovery = msg.payload; a.hostname = (msg.payload && msg.payload.hostname) || a.hostname; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
|
||||
loadAgents();
|
||||
// Re-fetch periodically to reflect connect/disconnect + isOnline (90s window).
|
||||
setInterval(loadAgents, 30000);
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+245
-17
@@ -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);
|
||||
@@ -1545,15 +1651,28 @@
|
||||
discoveryPlugins.forEach(p => {
|
||||
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
|
||||
// lastError/lastLog) but was dropped here; show it so a plugin that errors
|
||||
// is visible without digging into logs.
|
||||
const runOk = p.lastStatus === 'ok';
|
||||
const runErr = p.lastStatus === 'error';
|
||||
const runState = p.lastRunAt
|
||||
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
|
||||
: '<span class="text-muted">Never run</span>';
|
||||
const logsBtn = (p.lastLog || p.lastError)
|
||||
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
|
||||
: '';
|
||||
const card = `
|
||||
<div class="card mb-3 border shadow-sm">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||
<div class="small">Last run: ${runState}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||
${logsBtn}
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||
@@ -1565,6 +1684,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
|
||||
function fmtRunTs(ts) {
|
||||
if (!ts) return 'Never run';
|
||||
const m = moment(ts);
|
||||
return m.isValid() ? m.fromNow() : 'Never run';
|
||||
}
|
||||
|
||||
// Modal showing the discovery plugin's last run log + error (from the plugins
|
||||
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
|
||||
// <pre> rather than a toast.
|
||||
function showPluginLog(id) {
|
||||
const p = discoveryPlugins.find(x => x.id === id);
|
||||
if (!p) return;
|
||||
const body = p.lastError
|
||||
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
|
||||
: '';
|
||||
const log = p.lastLog || '(no log captured for this run)';
|
||||
app.modal.open({
|
||||
title: 'Run log — ' + (p.name || p.slug),
|
||||
size: 'lg',
|
||||
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleDiscoveryPlugin(id, state) {
|
||||
const endpoint = state ? 'load' : 'unload';
|
||||
try {
|
||||
@@ -1588,6 +1731,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 +1817,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 +1842,7 @@
|
||||
app.modal.open({
|
||||
title: 'Configure New Discovery Plugin',
|
||||
bodyHtml: bodyHtml,
|
||||
size: 'md'
|
||||
size: 'lg'
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1637,20 +1850,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 +1876,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>
|
||||
|
||||
|
||||
@@ -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') %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { %>
|
||||
|
||||
+85
-24
@@ -1,27 +1,32 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
|
||||
<ul class="nav nav-pills" id="vault-tabs">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||
<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>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="/docs/vault" class="text-reset" title="Vault help & documentation"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 +34,7 @@
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||
@@ -45,15 +50,17 @@
|
||||
<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/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
||||
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
|
||||
@@ -68,7 +75,7 @@
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm d-none" id="app-result-card">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">App token</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||
</div>
|
||||
@@ -83,15 +90,31 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/<name>/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
|
||||
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
@@ -102,7 +125,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>
|
||||
@@ -110,6 +133,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>
|
||||
|
||||
@@ -394,12 +422,44 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
document.getElementById('app-token').textContent = result.token;
|
||||
document.getElementById('app-name-display').textContent = name;
|
||||
document.getElementById('app-result-card').classList.remove('d-none');
|
||||
loadApps();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// List the minted external-app tokens (metadata only). Makes the Apps tab show
|
||||
// what's been minted instead of a credential that vanishes after the once-only
|
||||
// token display.
|
||||
async function loadApps() {
|
||||
const $list = document.getElementById('apps-list');
|
||||
if (!$list) return;
|
||||
$list.textContent = 'Loading…';
|
||||
try {
|
||||
const res = await fetch('/api/vault/apps', {
|
||||
headers: { 'auth-token': app.auth.getToken() }
|
||||
});
|
||||
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
|
||||
const { apps = [] } = await res.json();
|
||||
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
|
||||
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
|
||||
const ok = !a.lastError;
|
||||
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
||||
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
||||
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
||||
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
||||
</div>
|
||||
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
} catch (err) {
|
||||
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||
}
|
||||
@@ -561,6 +621,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
||||
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||
loadApps();
|
||||
}
|
||||
loadSecrets();
|
||||
loadShared();
|
||||
|
||||
Reference in New Issue
Block a user