Merge pull request #164 from theta42/release/v1.25.0
feat: hierarchical group & permission model (v1.25.0)
This commit is contained in:
@@ -1,3 +1,15 @@
|
|||||||
|
# v1.25.0
|
||||||
|
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<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
|
# 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: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
|
||||||
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
|
- fix: 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)
|
||||||
|
|||||||
@@ -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
|
## 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.
|
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
|
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."
|
||||||
@@ -3,6 +3,12 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
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 {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const DOCS = {
|
|||||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||||
plugins: {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')},
|
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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -59,12 +59,6 @@ router.get('/overview', function(req, res) {
|
|||||||
res.render('overview', {...values});
|
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('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/dashboard', (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});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/groups', function(req, res, next) {
|
|
||||||
res.render('groups', {...values});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/token', function(req, res, next) {
|
router.get('/token', function(req, res, next) {
|
||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
|
|||||||
// same answer in both modes.
|
// same answer in both modes.
|
||||||
const groups = await groupCns(user);
|
const groups = await groupCns(user);
|
||||||
user.groups = groups;
|
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);
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
GOD_ADMIN,
|
||||||
|
} = require('../utils/groups');
|
||||||
|
|
||||||
|
const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' };
|
||||||
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' };
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||||
|
expect(slugify('Web 01')).toBe('web-01');
|
||||||
|
expect(slugify('Main Office')).toBe('main-office');
|
||||||
|
expect(slugify('my_host')).toBe('my-host');
|
||||||
|
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||||
|
expect(slugify('')).toBe('');
|
||||||
|
});
|
||||||
|
test('never contains an underscore (the structural delimiter)', () => {
|
||||||
|
expect(slugify('a_b_c')).not.toContain('_');
|
||||||
|
expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group cn builders', () => {
|
||||||
|
test('per-resource uses singular kind', () => {
|
||||||
|
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
|
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||||
|
});
|
||||||
|
test('aggregate uses plural kind', () => {
|
||||||
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
|
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||||
|
});
|
||||||
|
test('site super admin + everyone', () => {
|
||||||
|
expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin');
|
||||||
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
|
});
|
||||||
|
test('invalid kind throws', () => {
|
||||||
|
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('levels', () => {
|
||||||
|
test('admin/access known; capabilities opaque', () => {
|
||||||
|
expect(isKnownLevel('admin')).toBe(true);
|
||||||
|
expect(isKnownLevel('access')).toBe(true);
|
||||||
|
expect(isKnownLevel('reboot')).toBe(false);
|
||||||
|
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(levelGrants('admin', 'access')).toBe(true);
|
||||||
|
expect(levelGrants('access', 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasPermission — inheritance', () => {
|
||||||
|
test('god_admin grants everything everywhere', () => {
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site super admin grants everything on its site, not other sites', () => {
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specific host group grants only that host', () => {
|
||||||
|
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
|
// aggregate capability
|
||||||
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
|
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
|
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||||
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-site isolation', () => {
|
||||||
|
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Theta42 group & permission model.
|
||||||
|
//
|
||||||
|
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||||
|
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||||
|
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||||
|
// uses the singular (host/app).
|
||||||
|
//
|
||||||
|
// god_admin global — everything, everywhere
|
||||||
|
// {site}_super_admin everything on the site
|
||||||
|
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||||
|
// {site}_hosts_<level>
|
||||||
|
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||||
|
// {site}_apps_<level> ... on ALL apps at the site
|
||||||
|
// {site}_app_<slug>_<level> ... on ONE app
|
||||||
|
// {site}_everyone / everyone meta groups (implicit membership)
|
||||||
|
//
|
||||||
|
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||||
|
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||||
|
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||||
|
//
|
||||||
|
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||||
|
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||||
|
|
||||||
|
const GOD_ADMIN = 'god_admin';
|
||||||
|
const KNOWN_LEVELS = ['admin', 'access'];
|
||||||
|
const KINDS = ['host', 'app'];
|
||||||
|
|
||||||
|
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||||
|
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a kind (host/app) — throw on anything else.
|
||||||
|
function assertKind(kind) {
|
||||||
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_host_<slug>_<level> / {site}_app_<slug>_<level>
|
||||||
|
function resourceGroupCns(site, kind, slug, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
|
function aggregateGroupCns(site, kind, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${slugify(site)}_${kind}s_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_super_admin
|
||||||
|
function siteSuperAdminCns(site) {
|
||||||
|
return `${slugify(site)}_super_admin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_everyone
|
||||||
|
function siteEveryoneCns(site) {
|
||||||
|
return `${slugify(site)}_everyone`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `level` is a known admin/access level (not an opaque capability).
|
||||||
|
function isKnownLevel(level) {
|
||||||
|
return KNOWN_LEVELS.includes(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if holding `level` grants `wanted` (admin implies access).
|
||||||
|
function levelGrants(level, wanted) {
|
||||||
|
if (level === wanted) return true;
|
||||||
|
return level === 'admin' && wanted === 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||||
|
// `level` on a resource. Applies the inheritance lattice:
|
||||||
|
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||||
|
//
|
||||||
|
// memberOf: array of group cns the user is a member of.
|
||||||
|
// resource: { site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
//
|
||||||
|
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||||
|
// are resource-level grants, resolved by the caller against the resource's own
|
||||||
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
|
// the user's membership only.
|
||||||
|
function hasPermission(memberOf, resource, level) {
|
||||||
|
const site = slugify(resource && resource.site);
|
||||||
|
const kind = resource && resource.kind;
|
||||||
|
const slug = slugify(resource && resource.slug);
|
||||||
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
|
if (set.has(siteSuperAdminCns(site))) return true;
|
||||||
|
|
||||||
|
if (isKnownLevel(level)) {
|
||||||
|
// admin / access
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
||||||
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GOD_ADMIN,
|
||||||
|
KNOWN_LEVELS,
|
||||||
|
KINDS,
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
};
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
const groups = require('./groups');
|
||||||
|
|
||||||
|
// The group nested into every resource's _admin group by api_directory_admin
|
||||||
|
// (cross-resource super-admin administration). KEEP the legacy `app_super_admin`
|
||||||
|
// here: it is the group that actually exists and gets nested. The new schema's
|
||||||
|
// global `god_admin` is recognized in isSuperAdmin() below, and api_directory_admin
|
||||||
|
// nests SUPER_ADMIN_GROUP -- so until `god_admin` is created during bootstrap, this
|
||||||
|
// must stay `app_super_admin` or resource auto-provisioning's nesting silently
|
||||||
|
// no-ops (leaving only the creator as the group's sole member).
|
||||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||||
|
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
// True if the user (by resolved member cns) is a global god/super admin.
|
||||||
|
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
|
||||||
|
async function isSuperAdmin(memberOfCns) {
|
||||||
|
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||||
|
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let byGroup = async function(user, checkGroups, ownerOf){
|
||||||
// Membership is resolved once, transitively: a user placed in an admin group
|
// 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.
|
// 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 --
|
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||||
@@ -17,9 +33,9 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
// they still catch direct membership if the resolver is unavailable.
|
// 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;
|
if(memberOfCns.includes(group)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,4 +58,46 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
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.
|
// Catalog requires login - it's the end-user view of their accessible resources.
|
||||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
|
{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: '/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: '/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']},
|
{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>/*.
|
// Vault requires login - per-user secrets at secret/users/<uid>/*.
|
||||||
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
|
{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']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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') %>
|
|
||||||
+189
-16
@@ -30,6 +30,7 @@
|
|||||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<i class="fa-solid fa-server"></i> Directory Management
|
<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>
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<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;">
|
<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}}">
|
<tr id="resource-row-{{id}}">
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
{{{indentHtml}}}
|
{{{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>
|
<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-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||||
@@ -235,7 +237,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label">Slug</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -476,6 +479,7 @@
|
|||||||
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
||||||
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||||
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||||
|
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
|
||||||
],
|
],
|
||||||
footer: {
|
footer: {
|
||||||
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||||
@@ -521,15 +525,23 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
|
||||||
|
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
|
||||||
|
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
||||||
|
var agentsByHost = {};
|
||||||
|
var agentsByToken = {};
|
||||||
|
|
||||||
async function loadResources() {
|
async function loadResources() {
|
||||||
try {
|
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/resources'),
|
||||||
app.api.get('directory-admin/groups'),
|
app.api.get('directory-admin/groups'),
|
||||||
app.api.get('directory-admin/edges'),
|
app.api.get('directory-admin/edges'),
|
||||||
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
||||||
// the table still renders, just without the Access column populated.
|
// the table still renders, just without the Access column populated.
|
||||||
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; })
|
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
|
||||||
|
// Agents are a nicety too: never block the directory on them.
|
||||||
|
app.api.get('agent/nodes').catch(function(){ return {agents: []}; })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
accessSummary = (resAccess && resAccess.results) || {};
|
accessSummary = (resAccess && resAccess.results) || {};
|
||||||
@@ -540,6 +552,8 @@
|
|||||||
resourcesById[r.id] = r;
|
resourcesById[r.id] = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
indexAgents((resAgents && resAgents.agents) || []);
|
||||||
|
|
||||||
allGroups = resGroups.results;
|
allGroups = resGroups.results;
|
||||||
allEdges = resEdges.results;
|
allEdges = resEdges.results;
|
||||||
|
|
||||||
@@ -572,6 +586,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
|
||||||
|
function indexAgents(agents) {
|
||||||
|
agentsByHost = {};
|
||||||
|
agentsByToken = {};
|
||||||
|
for (const a of agents || []) {
|
||||||
|
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
|
||||||
|
if (hn) agentsByHost[hn] = a;
|
||||||
|
if (a.token) agentsByToken[a.token] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
|
||||||
|
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
|
||||||
|
|
||||||
|
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
|
||||||
|
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
|
||||||
|
// stores the agent on resourcesById so the Metrics tab can find it.
|
||||||
|
function attachAgentStatus(n) {
|
||||||
|
n.isHost = true;
|
||||||
|
const name = (n.name || '').toLowerCase();
|
||||||
|
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
|
||||||
|
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
|
||||||
|
n.agent = a || null;
|
||||||
|
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||||
|
if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; }
|
||||||
|
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
|
||||||
|
const t = a.telemetry || {};
|
||||||
|
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||||
|
n.agentColor = high ? '#ffc107' : '#198754';
|
||||||
|
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics tab body for the resource modal (snapshot of the joined agent).
|
||||||
|
function metricsTabHtml(agent) {
|
||||||
|
if (!agent) {
|
||||||
|
return '<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) || []);
|
||||||
|
renderTable();
|
||||||
|
} catch (e) { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
// "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
|
// 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
|
// entry has been deleted grants nothing, so both get called out rather than
|
||||||
@@ -679,6 +766,7 @@
|
|||||||
}
|
}
|
||||||
n.indentHtml = indentHtml;
|
n.indentHtml = indentHtml;
|
||||||
n.accessHtml = accessCellHtml(n.id);
|
n.accessHtml = accessCellHtml(n.id);
|
||||||
|
if (n.kind === 'host') attachAgentStatus(n);
|
||||||
finalRenderList.push(n);
|
finalRenderList.push(n);
|
||||||
if (n.children.length > 0) {
|
if (n.children.length > 0) {
|
||||||
flatten(n.children, depth + 1);
|
flatten(n.children, depth + 1);
|
||||||
@@ -1588,6 +1676,79 @@
|
|||||||
|
|
||||||
var discoveryPluginTypes = [];
|
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() {
|
function openNewDiscoveryPluginModal() {
|
||||||
app.api.get('plugins/types', function(err, res) {
|
app.api.get('plugins/types', function(err, res) {
|
||||||
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||||
@@ -1601,25 +1762,22 @@
|
|||||||
const bodyHtml = `
|
const bodyHtml = `
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Plugin Type</label>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Instance Name</label>
|
<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">
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Slug</label>
|
<label class="form-label fw-bold">Schedule</label>
|
||||||
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
|
${dpCronSelectHtml('np-', '0 * * * *')}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
|
<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>
|
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
|
||||||
</div>
|
</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">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
|
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
|
||||||
@@ -1629,7 +1787,7 @@
|
|||||||
app.modal.open({
|
app.modal.open({
|
||||||
title: 'Configure New Discovery Plugin',
|
title: 'Configure New Discovery Plugin',
|
||||||
bodyHtml: bodyHtml,
|
bodyHtml: bodyHtml,
|
||||||
size: 'md'
|
size: 'lg'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1637,20 +1795,20 @@
|
|||||||
async function saveNewDiscoveryPlugin() {
|
async function saveNewDiscoveryPlugin() {
|
||||||
const type = $('#new-plugin-type').val();
|
const type = $('#new-plugin-type').val();
|
||||||
const name = $('#new-plugin-name').val().trim();
|
const name = $('#new-plugin-name').val().trim();
|
||||||
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const cron = dpCronFromForm('np-');
|
||||||
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
|
|
||||||
const enabled = $('#new-plugin-enabled').is(':checked');
|
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');
|
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.api.post('plugins', {
|
await app.api.post('plugins', {
|
||||||
pluginType: type,
|
pluginType: type,
|
||||||
name,
|
name,
|
||||||
slug,
|
|
||||||
cron,
|
cron,
|
||||||
enabled,
|
enabled,
|
||||||
config: {}
|
config
|
||||||
});
|
});
|
||||||
app.messages.toast('Discovery plugin created successfully!', 'success');
|
app.messages.toast('Discovery plugin created successfully!', 'success');
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
@@ -1663,6 +1821,21 @@
|
|||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
loadDiscoveryResources();
|
loadDiscoveryResources();
|
||||||
loadDiscoveryPlugins();
|
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>
|
</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') %>
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<% if(ui.profileUrl){ %>
|
<% 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>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|||||||
+29
-18
@@ -1,27 +1,29 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
<div class="container mt-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="row">
|
||||||
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
|
<div class="col-12">
|
||||||
<ul class="nav nav-pills" id="vault-tabs">
|
<div class="card shadow">
|
||||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
<div class="card-header">
|
||||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
|
||||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</button></li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade show active" id="tab-secrets">
|
<div class="tab-pane fade show active" id="tab-secrets">
|
||||||
<div class="d-flex justify-content-end mb-3">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
||||||
<i class="fas fa-plus"></i> New Secret
|
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card shadow-sm">
|
<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 list-group-flush" id="secrets-list">
|
||||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
<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>
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||||
@@ -47,13 +49,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
||||||
<div class="tab-pane fade" id="tab-apps">
|
<div class="tab-pane fade" id="tab-apps">
|
||||||
|
<div class="p-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div class="card shadow-sm">
|
<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">
|
<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">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>
|
<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 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card shadow-sm d-none" id="app-result-card">
|
<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>
|
<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>
|
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,13 +89,15 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="tab-shared">
|
<div class="tab-pane fade" id="tab-shared">
|
||||||
|
<div class="p-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<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>
|
<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>
|
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +108,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<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 list-group-flush" id="shared-granted-list">
|
||||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +116,11 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user