Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 | |||
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 |
@@ -19,6 +19,9 @@
|
|||||||
!API.md
|
!API.md
|
||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
|
||||||
|
# in Dockerfile.openldap needs these present in the build context.
|
||||||
|
!docs/images/**
|
||||||
|
|
||||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
# nodejs/tests/
|
# nodejs/tests/
|
||||||
|
|||||||
@@ -1,3 +1,47 @@
|
|||||||
|
# v1.26.1
|
||||||
|
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
||||||
|
|
||||||
|
# v1.26.0
|
||||||
|
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
|
||||||
|
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
|
||||||
|
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
|
||||||
|
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
|
||||||
|
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
|
||||||
|
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
|
||||||
|
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
|
||||||
|
|
||||||
|
# v1.25.0
|
||||||
|
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
||||||
|
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
||||||
|
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
|
||||||
|
|
||||||
|
# v1.24.0
|
||||||
|
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
|
||||||
|
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
|
||||||
|
- feat: Directory resource slug is now read-only and derived from the name
|
||||||
|
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
|
||||||
|
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
|
||||||
|
|
||||||
|
# v1.23.0
|
||||||
|
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
|
||||||
|
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
|
||||||
|
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
|
||||||
|
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
|
||||||
|
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
|
||||||
|
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
|
||||||
|
|
||||||
|
# v1.22.0
|
||||||
|
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
|
||||||
|
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
|
||||||
|
|
||||||
|
# v1.21.0
|
||||||
|
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
|
||||||
|
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
|
||||||
|
- feat: shared-secrets API + Shared tab in the vault UI
|
||||||
|
|
||||||
|
# v1.20.0
|
||||||
|
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
|
||||||
|
|
||||||
## v1.19.0
|
## v1.19.0
|
||||||
- Added WebSocket endpoint for theta-agent C2
|
- Added WebSocket endpoint for theta-agent C2
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ COPY README.md /README.md
|
|||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||||
|
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||||
|
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||||
|
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||||
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|||||||
+12
-8
@@ -355,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
#
|
||||||
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||||
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||||
|
# the theta-suite bootstrap puts the first admin person into it.
|
||||||
|
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||||
# hold those rights by membership rather than by a special case in app code.
|
# by membership rather than by a special case in app code. This is what makes
|
||||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||||
# anything binding LDAP directly -- instead of only to callers that happen
|
# LDAP directly -- instead of only to callers that happen to route through
|
||||||
# to route through utils/permission.js.
|
# utils/permission.js.
|
||||||
#
|
#
|
||||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
# non-person accounts, not a permission, and nesting admins into it would
|
# non-person accounts, not a permission, and nesting admins into it would
|
||||||
@@ -381,10 +385,10 @@ EOF
|
|||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
changetype: modify
|
changetype: modify
|
||||||
add: member
|
add: member
|
||||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
info "Nested app_super_admin into the SSO admin groups"
|
info "Nested god_admin into the SSO admin groups"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
|
|||||||
+115
-67
@@ -1,88 +1,136 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Discovery Agents
|
title: Theta Agent & Endpoint Management
|
||||||
nav_order: 5
|
nav_order: 5
|
||||||
---
|
---
|
||||||
|
|
||||||
# Discovery Agents
|
# Theta Agent & Endpoint Management
|
||||||
|
|
||||||
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||||
|
|
||||||
## Writing a Custom Agent
|
---
|
||||||
|
|
||||||
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
## Core Functionality
|
||||||
|
|
||||||
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
### 1. Host Discovery & Inventory
|
||||||
|
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
|
||||||
|
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
|
||||||
|
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
|
||||||
|
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
|
||||||
|
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
|
||||||
|
|
||||||
### Agent Skeleton
|
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
|
||||||
|
|
||||||
```javascript
|
### 2. Real-Time Telemetry Streaming
|
||||||
// nodejs/agents/discovery/my_custom_agent.js
|
Every 30 seconds, the agent streams real-time performance metrics:
|
||||||
module.exports = {
|
- **CPU Load**: System-wide CPU utilization percentage.
|
||||||
discover: async (config) => {
|
- **Memory Utilization**: RAM usage percentage and available memory.
|
||||||
const { url, apiKey } = config; // Provided by your configuration
|
- **Disk Utilization**: Root filesystem usage percentage.
|
||||||
|
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
|
||||||
const resources = [];
|
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
|
||||||
const edges = [];
|
|
||||||
|
|
||||||
// 1. Fetch your data from an API
|
---
|
||||||
// const data = await fetch(...);
|
|
||||||
|
|
||||||
// 2. Map data to Resources
|
## Viewing in the SSO Manager
|
||||||
resources.push({
|
|
||||||
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
|
||||||
name: 'My Switch',
|
|
||||||
slug: 'my-switch-01',
|
|
||||||
metadata: {
|
|
||||||
make: 'Vendor',
|
|
||||||
model: 'Model X',
|
|
||||||
interfaces: [
|
|
||||||
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Map relations to Edges (optional)
|
Agent status and telemetry live on the **Directory** page — there is no separate
|
||||||
edges.push({
|
Agents page. For each **host** resource that has a connected theta-agent, the
|
||||||
parentSlug: 'my-switch-01',
|
Directory shows a status dot in the row:
|
||||||
childSlug: 'some-connected-client-slug',
|
|
||||||
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
|
||||||
});
|
|
||||||
|
|
||||||
return { resources, edges };
|
| Color | Meaning |
|
||||||
}
|
| :--- | :--- |
|
||||||
};
|
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||||
|
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
||||||
|
| **Red** | Not connected (no agent, or the agent is offline). |
|
||||||
|
|
||||||
|
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||||
|
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||||
|
The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the
|
||||||
|
resource name), so name the Directory host the same as the machine's hostname.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-First Security & Capability Matrix
|
||||||
|
|
||||||
|
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
|
||||||
|
|
||||||
|
| Capability | Config Key | Risk Level | Description & Impact |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
|
||||||
|
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
|
||||||
|
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
|
||||||
|
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
|
||||||
|
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Risk Command Verification (Protocol v1.1.0)
|
||||||
|
|
||||||
|
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
|
||||||
|
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
|
||||||
|
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||||
|
3. The Base64 signature is appended to the message payload.
|
||||||
|
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Deployment
|
||||||
|
|
||||||
|
### Quick One-Liner Install
|
||||||
|
Run the following command as `root` on the target Linux host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
### Custom Config Wizard
|
||||||
|
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
|
||||||
|
|
||||||
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||||
```javascript
|
|
||||||
module.exports = {
|
|
||||||
// ... existing config ...
|
|
||||||
discovery: {
|
|
||||||
agents: {
|
|
||||||
my_custom_agent: {
|
|
||||||
enabled: true,
|
|
||||||
cron: '*/30 * * * *', // Run every 30 minutes
|
|
||||||
url: 'https://api.example.com',
|
|
||||||
apiKey: 'secret-key'
|
|
||||||
},
|
|
||||||
nmap: {
|
|
||||||
enabled: true,
|
|
||||||
cron: '0 * * * *',
|
|
||||||
targetRange: '192.168.1.0/24'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## The Reconciliation Engine
|
---
|
||||||
|
|
||||||
|
## Configuration File Example (`/etc/theta42/agent.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/theta42/agent.yml
|
||||||
|
server_url: "wss://sso.example.com"
|
||||||
|
auth_token: "your-unique-host-token"
|
||||||
|
location: "dc-01-rack-12"
|
||||||
|
public_key: "MCowBQYDK2VwAyEA..."
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
reboot: false
|
||||||
|
service_control: ["nginx", "docker", "sssd"]
|
||||||
|
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).
|
||||||
|
|
||||||
|
|
||||||
When your agent returns its graph, the Reconciliation Engine takes over:
|
|
||||||
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
|
||||||
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
|
|
||||||
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
|
||||||
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
|
||||||
|
|||||||
+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."
|
||||||
@@ -60,6 +60,7 @@ backend, that's the niche.
|
|||||||
run the pieces separately via `app_*` env config.
|
run the pieces separately via `app_*` env config.
|
||||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||||
|
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
|||||||
const vaultBroker = require('./utils/vault_broker');
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
|
||||||
|
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ models.initORM().then(() => {
|
|||||||
initScheduler(conf.discovery).catch(err => {
|
initScheduler(conf.discovery).catch(err => {
|
||||||
console.error('Failed to initialize scheduler:', err);
|
console.error('Failed to initialize scheduler:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep external-app vault tokens alive: renew every stored accessor now and
|
||||||
|
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
|
||||||
|
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
|
||||||
|
if (process.env.VAULT_TOKEN) {
|
||||||
|
require('../utils/vault_broker').startAppTokenRenewal();
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Binary file not shown.
@@ -17,6 +17,9 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
|||||||
const { AccessRequest } = require('./access_request');
|
const { AccessRequest } = require('./access_request');
|
||||||
const { Webhook } = require('./webhook');
|
const { Webhook } = require('./webhook');
|
||||||
const { PluginInstance } = require('./plugin_instance');
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const { SharedSecret } = require('./shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -31,15 +34,48 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
|
SharedSecret, SharedSecretGrant, VaultAppToken,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log('[initORM] ORM initialized successfully');
|
console.log('[initORM] ORM initialized successfully');
|
||||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||||
|
await healSchema();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[initORM] ORM initialization failed:', err.message);
|
console.error('[initORM] ORM initialization failed:', err.message);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||||
|
// which creates missing tables but never touches existing ones — so a column
|
||||||
|
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||||
|
// in an upgraded deployment's database and every query on the model fails
|
||||||
|
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||||
|
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||||
|
// any dialect via the query interface, and fail-soft per column so one bad
|
||||||
|
// attribute can't take the boot down.
|
||||||
|
async function healSchema() {
|
||||||
|
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||||
|
if (!adapter || !adapter.sequelize) return;
|
||||||
|
const sequelize = adapter.sequelize;
|
||||||
|
const qi = sequelize.getQueryInterface();
|
||||||
|
for (const SM of Object.values(sequelize.models)) {
|
||||||
|
const table = SM.getTableName();
|
||||||
|
let existing;
|
||||||
|
try { existing = await qi.describeTable(table); }
|
||||||
|
catch (e) { continue; } // no table yet — sync() handles creation
|
||||||
|
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||||
|
const col = attr.field || name;
|
||||||
|
if (existing[col]) continue;
|
||||||
|
try {
|
||||||
|
await qi.addColumn(table, col, attr);
|
||||||
|
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.initORM = initORM;
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecret — a secret the owner has published to the shared namespace so it
|
||||||
|
// can be shared with other users and/or downstream apps.
|
||||||
|
//
|
||||||
|
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
|
||||||
|
// never in the DB. This row is metadata only (owner + slug + description) and is
|
||||||
|
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
|
||||||
|
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
|
||||||
|
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
|
||||||
|
// edited to add `read` on the exact shared path (see vault_broker.js — policy
|
||||||
|
// content is parsed live at token use, so a grant takes effect immediately with
|
||||||
|
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
|
||||||
|
//
|
||||||
|
// `slug` is unique and immutable in practice — it is embedded in the shared path
|
||||||
|
// and in grantee policy rules, so changing it would require rewriting policies.
|
||||||
|
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
|
||||||
|
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
|
||||||
|
// ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class SharedSecret extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
|
||||||
|
// Unique so two owners can't collide on the same shared path.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The publishing user's uid — also the shared path's namespace segment.
|
||||||
|
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Optional human description shown in the Shared tab.
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
|
||||||
|
static pathFor(ownerUid, slug) {
|
||||||
|
return `shared/${ownerUid}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
path() {
|
||||||
|
return SharedSecret.pathFor(this.ownerUid, this.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug (unique). Returns the row or null.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecret };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
|
||||||
|
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
|
||||||
|
// <secretId>".
|
||||||
|
//
|
||||||
|
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
|
||||||
|
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
|
||||||
|
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
|
||||||
|
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
|
||||||
|
// content live at token use, the grant applies to the grantee's existing token
|
||||||
|
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
|
||||||
|
//
|
||||||
|
// granteeType distinguishes the two principal kinds:
|
||||||
|
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
|
||||||
|
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
|
||||||
|
// capability is currently always 'read' (grantees are read-only); the column is
|
||||||
|
// a string so later capabilities could be added without a migration.
|
||||||
|
//
|
||||||
|
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
|
||||||
|
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const GRANTEE_TYPES = ['user', 'app'];
|
||||||
|
const CAPABILITIES = ['read'];
|
||||||
|
|
||||||
|
class SharedSecretGrant extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// FK to SharedSecret.id.
|
||||||
|
secretId: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
|
||||||
|
granteeType: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// The grantee's uid (for 'user') or app name (for 'app').
|
||||||
|
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Access level — 'read' today.
|
||||||
|
capability: { type: 'string', isRequired: true, default: 'read' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All grants for a given grantee (user uid or app name). Used to rebuild the
|
||||||
|
// grantee's policy content so every granted shared path is present/absent.
|
||||||
|
static async listForGrantee(granteeType, granteeId) {
|
||||||
|
return this.list({ where: { granteeType, granteeId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
|
||||||
|
// from the vault UI (Apps tab), so sso can keep the token alive.
|
||||||
|
//
|
||||||
|
// The token itself is shown ONCE at mint and never stored (a stolen accessor
|
||||||
|
// cannot authenticate — it can only look up, renew, or revoke its token, and
|
||||||
|
// only the sso broker's policy grants those endpoints). App tokens are minted
|
||||||
|
// through the sso-app role as PERIODIC tokens: they live forever, but only if
|
||||||
|
// something renews them inside every period window. That something is sso's
|
||||||
|
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
|
||||||
|
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
|
||||||
|
// stays valid as long as sso itself is running, with no renewal code needed in
|
||||||
|
// the downstream app.
|
||||||
|
//
|
||||||
|
// One row per app name: re-minting an app's token revokes the previous token
|
||||||
|
// via its accessor (no zombie credentials) and replaces the row.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class VaultAppToken extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The external app's name — also its policy (app-<name>) and KV namespace
|
||||||
|
// (secret/apps/<name>/). Unique: one live token per app.
|
||||||
|
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The minted token's accessor (renew/revoke handle, cannot authenticate).
|
||||||
|
accessor: { type: 'string', isRequired: true, max: 128 },
|
||||||
|
// Renewal bookkeeping, updated by the renewal loop.
|
||||||
|
lastRenewedAt: { type: 'integer' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
static async getByName(name) {
|
||||||
|
const rows = await this.list({ where: { name } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VaultAppToken };
|
||||||
Generated
+18
-18
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.6",
|
"version": "1.26.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.6",
|
"version": "1.26.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -2344,9 +2344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -4294,9 +4294,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -5966,16 +5966,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
@@ -7726,9 +7726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7918,9 +7918,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.19.6",
|
"version": "1.26.1",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
const agentManager = require('../utils/agent_manager');
|
const agentManager = require('../utils/agent_manager');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
module.exports = function initAgentWebSockets(app) {
|
module.exports = function initAgentWebSockets(app) {
|
||||||
if (!app.wss) {
|
if (!app.wss) {
|
||||||
console.warn("WebSocket server for agents is not initialized.");
|
console.warn("WebSocket server for agents is not initialized.");
|
||||||
@@ -71,8 +75,23 @@ module.exports = function initAgentWebSockets(app) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// REST API routes for Agent Management (mounted under /api/agent)
|
// REST API routes for Agent Management (mounted under /api/agent). The agent
|
||||||
|
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
|
||||||
|
// bin/www with its own ?token= auth — unaffected by the express middleware
|
||||||
|
// here. These REST routes are admin-facing, so they're auth + admin gated.
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
router.use(middleware.auth);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/nodes', (req, res) => {
|
router.get('/nodes', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
|
|||||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
const groups = require('../utils/groups');
|
||||||
|
|
||||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
// the goal state, and a missing group (e.g. app_super_admin absent on a
|
// the goal state, and a missing group (e.g. god_admin absent on a
|
||||||
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||||
// caller's real work.
|
// caller's real work.
|
||||||
async function nestGroup(childCn, parentCn) {
|
async function nestGroup(childCn, parentCn) {
|
||||||
@@ -29,6 +30,148 @@ async function nestGroup(childCn, parentCn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
|
// The directory is the single place groups are created, as a projection of the
|
||||||
|
// resource graph. These helpers materialize the group-inheritance lattice for
|
||||||
|
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
|
||||||
|
// All of them are idempotent, so calling them again for a resource a newer
|
||||||
|
// release is backfilling is a no-op.
|
||||||
|
|
||||||
|
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
|
||||||
|
// host -> host; service -> app (services/consoles are the group model's "apps");
|
||||||
|
// site gets site-level groups (handled separately); oauth/container get no
|
||||||
|
// per-resource groups (oauth clients hang off their owning service).
|
||||||
|
function groupKind(resource) {
|
||||||
|
if (resource.kind === 'host') return 'host';
|
||||||
|
if (resource.kind === 'service') return 'app';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
|
||||||
|
// seeds the mandatory first member. Returns true when created.
|
||||||
|
async function ensureGroup(name, ownerDn, description) {
|
||||||
|
try {
|
||||||
|
await Group.add({ name, owner: ownerDn, description });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||||
|
console.error(`ensureGroup: failed to create ${name}:`, err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||||
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
|
// older release gets its groups without a rebuild:
|
||||||
|
//
|
||||||
|
// god_admin -> {site}_super_admin
|
||||||
|
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
|
||||||
|
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
|
||||||
|
//
|
||||||
|
// `{site}_everyone` is created for completeness; it has implicit membership and
|
||||||
|
// is granted to a resource as a grantee, never enumerated.
|
||||||
|
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
||||||
|
if (!siteSlug) return;
|
||||||
|
|
||||||
|
// Link a site group to the site resource (so it shows + is member-manageable
|
||||||
|
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
|
||||||
|
// groups as member.
|
||||||
|
const link = async (cn, isAdmin) => {
|
||||||
|
if (!siteResourceId) return;
|
||||||
|
await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
|
await link(sAdmin, true);
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
|
await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
|
await link(aggAdmin, true);
|
||||||
|
await link(aggAccess, false);
|
||||||
|
}
|
||||||
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
|
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||||
|
// god_admin is the global group; surface it on the site modal so its members
|
||||||
|
// can be managed from the Directory (it has no home on a single resource).
|
||||||
|
await link(groups.GOD_ADMIN, true);
|
||||||
|
|
||||||
|
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
|
||||||
|
// binding directly) resolve it transitively, not just utils/permission.js.
|
||||||
|
// nestGroup(child, parent) makes child a member of parent -- membership flows
|
||||||
|
// child -> parent ("up"), so a group's members inherit what its parents hold.
|
||||||
|
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
|
||||||
|
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the per-resource groups for a host/app and nest them into the site
|
||||||
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
|
// The specific group name uses the resource's slug verbatim
|
||||||
|
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
|
||||||
|
// `kind` (host/app) selects which aggregate the group nests into:
|
||||||
|
//
|
||||||
|
// {site}_{slug}_admin -> {site}_{slug}_access
|
||||||
|
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
|
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
|
// god_admin -> {site}_{slug}_admin (global super admin)
|
||||||
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
|
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
|
||||||
|
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin');
|
||||||
|
|
||||||
|
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
|
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||||
|
|
||||||
|
// Link both groups to the resource so the Directory can show/revoke them.
|
||||||
|
await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {});
|
||||||
|
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
|
||||||
|
|
||||||
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
|
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||||
|
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||||
|
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||||
|
// §2/§3). This is what "force the correct naming convention" means: a group
|
||||||
|
// linked to a resource must be one that parses for consumers -- the resource's
|
||||||
|
// own specific groups, its site's aggregates, site-level groups, or the global
|
||||||
|
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
|
||||||
|
// capability groups following the same shapes.
|
||||||
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
|
const valid = new Set();
|
||||||
|
if (resource.kind === 'site') {
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
for (const k of ['host', 'app']) {
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, k, 'access'));
|
||||||
|
}
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
|
if (kind) {
|
||||||
|
const slug = resource.slug; // verbatim (kind is carried in the slug)
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, slug, 'access'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
return { valid, capRe: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +193,36 @@ router.get('/resources', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
|
||||||
|
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||||
|
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||||
|
// aggregates, and every host/app resource has its per-resource groups nested
|
||||||
|
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||||
|
// backfills a directory seeded by an older release without a rebuild.
|
||||||
|
// Never fails the list.
|
||||||
|
const sites = resources.filter(r => r.kind === 'site');
|
||||||
|
await Promise.all(sites.map(site =>
|
||||||
|
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||||
|
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||||
|
));
|
||||||
|
const siteByResource = new Map();
|
||||||
|
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||||
|
const siteOf = async (r) => {
|
||||||
|
const direct = siteByResource.get(r.id);
|
||||||
|
if (direct) return direct;
|
||||||
|
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||||
|
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||||
|
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||||
|
};
|
||||||
|
await Promise.all(resources.map(async (r) => {
|
||||||
|
const gKind = groupKind(r);
|
||||||
|
if (!gKind) return;
|
||||||
|
const siteSlug = await siteOf(r);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -95,46 +268,25 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
// Materialize the group-model for the new resource. Site resources get the
|
||||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
// site-level groups; host/app resources get their per-resource groups nested
|
||||||
|
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
// older release. A provisioning failure must not fail resource creation: the
|
||||||
const cn = groupCn(suffix);
|
// resource already exists and the groups are repairable (re-run ensures them).
|
||||||
try {
|
//
|
||||||
await Group.add({
|
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||||
name: cn,
|
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||||
owner: req.user.dn,
|
// the kind prefix themselves.
|
||||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
const gKind = groupKind(r);
|
||||||
});
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
} catch (err) {
|
if (r.kind === 'site') {
|
||||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
} else if (gKind && ancestorSite) {
|
||||||
}
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
}
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
try {
|
|
||||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
|
||||||
} catch(err) { /* ignore duplicate links */ }
|
|
||||||
};
|
|
||||||
await createGroup('access', 'member');
|
|
||||||
await createGroup('admin', 'owner');
|
|
||||||
|
|
||||||
// Wire up the two standing relationships every resource has, as nesting
|
|
||||||
// rather than as membership that has to be maintained per resource:
|
|
||||||
//
|
|
||||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
|
||||||
// every resource, automatically
|
|
||||||
// <slug>_admin -> <slug>_access administering something implies
|
|
||||||
// being able to use it
|
|
||||||
//
|
|
||||||
// Before nesting, both of these could only be expressed by adding every
|
|
||||||
// super admin to every new group by hand -- which nobody does, so the
|
|
||||||
// groups drifted. A failure here must not fail resource creation: the
|
|
||||||
// resource and its groups already exist and the nesting is repairable.
|
|
||||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
|
||||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
@@ -265,6 +417,28 @@ router.get('/groups', async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/groups', async (req, res, next) => {
|
router.post('/groups', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { resourceId, groupCn } = req.body;
|
||||||
|
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
|
||||||
|
|
||||||
|
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
|
||||||
|
// be a valid group for this resource; reject free-form names so the groups
|
||||||
|
// consumers read are always parseable. god_admin is always allowed (it is
|
||||||
|
// the global group and is managed from a site's modal).
|
||||||
|
const resource = await Resource.get(resourceId);
|
||||||
|
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
|
||||||
|
// site resource's own slug is its site; a host/app uses its ancestor site.
|
||||||
|
const siteSlug = resource && resource.kind === 'site'
|
||||||
|
? resource.slug
|
||||||
|
: await Resource.findAncestorSiteSlug(resourceId);
|
||||||
|
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
|
||||||
|
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
|
||||||
|
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
|
||||||
|
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const g = await ResourceGroup.create(req.body);
|
const g = await ResourceGroup.create(req.body);
|
||||||
res.json({ results: g });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
@@ -313,7 +487,7 @@ router.get('/access-summary', async (req, res, next) => {
|
|||||||
//
|
//
|
||||||
// Counts come from the transitive closure, not from `member`. Reading the
|
// Counts come from the transitive closure, not from `member`. Reading the
|
||||||
// attribute would report only who is listed on the group, missing anyone
|
// attribute would report only who is listed on the group, missing anyone
|
||||||
// who reaches it through a nested group -- and since app_super_admin is
|
// who reaches it through a nested group -- and since god_admin is
|
||||||
// nested into every resource's _admin group, that is not an edge case.
|
// nested into every resource's _admin group, that is not an edge case.
|
||||||
let members = [];
|
let members = [];
|
||||||
if (group) {
|
if (group) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Shared-secrets API.
|
||||||
|
//
|
||||||
|
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
|
||||||
|
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
|
||||||
|
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
|
||||||
|
// OpenBao policy content is edited to add read on the exact shared path (see
|
||||||
|
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
|
||||||
|
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
|
||||||
|
// immediately, with no token re-mint.
|
||||||
|
//
|
||||||
|
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
|
||||||
|
// them through the existing /api/vault proxy using the requester's own session
|
||||||
|
// token, so OpenBao ACL enforces read access per-request. This router handles
|
||||||
|
// metadata CRUD + grant management; KV writes (create/update/delete) are made
|
||||||
|
// server-side using the acting user's scoped token.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
|
||||||
|
// /api/vault proxy — personal, per-user secret management only).
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (req.user && req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
|
||||||
|
catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
|
||||||
|
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
|
||||||
|
// sso-admin token (R/W on secret/*).
|
||||||
|
async function actorToken(user, ownerUid) {
|
||||||
|
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
|
||||||
|
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does this user manage the given shared secret? Owner or admin.
|
||||||
|
async function canManage(user, secret) {
|
||||||
|
if (user.uid === secret.ownerUid) return true;
|
||||||
|
return isAdmin(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecret(req, res) {
|
||||||
|
const secret = await SharedSecret.get(req.params.id);
|
||||||
|
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List: mine + shared-with-me ─────────────────────────────────────────────
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('user', uid);
|
||||||
|
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const granted = granteeSecretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
|
||||||
|
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
|
||||||
|
for (const g of granted) {
|
||||||
|
if (byId.has(g.id)) continue; // already owner
|
||||||
|
byId.set(g.id, { role: 'grantee', ...g });
|
||||||
|
}
|
||||||
|
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const slug = String(req.body.slug || '').trim().toLowerCase();
|
||||||
|
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
|
||||||
|
const description = String(req.body.description || '').trim();
|
||||||
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||||
|
|
||||||
|
if (await SharedSecret.getBySlug(slug)) {
|
||||||
|
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
|
||||||
|
}
|
||||||
|
const token = await actorToken(req.user, uid);
|
||||||
|
if (!token) return res.status(403).json({ error: 'not allowed' });
|
||||||
|
const path = SharedSecret.pathFor(uid, slug);
|
||||||
|
await baoConf.set(path, data, { token });
|
||||||
|
|
||||||
|
const secret = await SharedSecret.create({
|
||||||
|
slug, ownerUid: uid, description,
|
||||||
|
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
|
||||||
|
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update data / description ───────────────────────────────────────────────
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
const update = {};
|
||||||
|
if (req.body && typeof req.body.data === 'object') {
|
||||||
|
await baoConf.set(secret.path(), req.body.data, { token });
|
||||||
|
}
|
||||||
|
if (req.body && req.body.description !== undefined) {
|
||||||
|
update.description = String(req.body.description).trim();
|
||||||
|
}
|
||||||
|
if (Object.keys(update).length) {
|
||||||
|
update.updated_by = req.user.uid;
|
||||||
|
update.updated_on = Date.now();
|
||||||
|
await secret.update(update);
|
||||||
|
}
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
// Revoke all grants first so grantees' policies drop the path.
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
|
||||||
|
// Delete the KV data (metadata delete removes all versions), then the row.
|
||||||
|
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
|
||||||
|
await secret.delete();
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: list ────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: create ──────────────────────────────────────────────────────────
|
||||||
|
router.post('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const granteeType = String(req.body.granteeType || '').trim();
|
||||||
|
const granteeId = String(req.body.granteeId || '').trim();
|
||||||
|
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
|
||||||
|
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
|
||||||
|
if (granteeId === secret.ownerUid && granteeType === 'user') {
|
||||||
|
return res.status(400).json({ error: 'the owner already has access' });
|
||||||
|
}
|
||||||
|
// Idempotent: skip if the grant already exists.
|
||||||
|
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
|
||||||
|
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
|
||||||
|
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
|
||||||
|
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: revoke ──────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id/grants/:grantId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grant = await SharedSecretGrant.get(req.params.grantId);
|
||||||
|
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
|
||||||
|
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
meta.managed = true;
|
||||||
await resource.update({ metadata: meta });
|
await Resource.update(resource.id, { metadata: meta });
|
||||||
|
|
||||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@@ -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')},
|
||||||
@@ -54,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
|
|||||||
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
||||||
// the rendered markup to point at it absolutely, so the images work when
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
// read from /docs/overview too.
|
// read from /docs/overview too.
|
||||||
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
||||||
function fixImagePaths(html) {
|
function fixImagePaths(html) {
|
||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-12
@@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/plugins', function(req, res, next) {
|
router.get('/plugins', function(req, res, next) {
|
||||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
res.redirect('/directory');
|
||||||
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
|
|
||||||
// client gates with app.auth.forceLogin(['app_sso_admin',
|
|
||||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
|
||||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
|
||||||
// /vault (auth-token is a client-set header, not a cookie).
|
|
||||||
const registry = require('../services/plugin_registry');
|
|
||||||
res.render('plugins', {...values, pluginTypes: registry.types });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/vault', function(req, res) {
|
router.get('/vault', function(req, res) {
|
||||||
@@ -195,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){
|
||||||
|
|||||||
@@ -12,32 +12,39 @@ class DiscoveryReconciler {
|
|||||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
let existing = null;
|
let existing = null;
|
||||||
|
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||||
// Attempt matching by MAC if available (case-insensitive)
|
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||||
|
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
|
||||||
|
// 1. Attempt matching by MAC (highest precision)
|
||||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||||
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
|
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||||
if (macs.length > 0) {
|
if (macs.length > 0) {
|
||||||
const allRes = await Resource.list();
|
|
||||||
existing = allRes.find(r =>
|
existing = allRes.find(r =>
|
||||||
r.metadata && r.metadata.interfaces &&
|
r.metadata && (
|
||||||
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
|
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||||
|
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback matching by IP if no MAC match (weaker)
|
// 2. Fallback matching by IP address
|
||||||
let ipsToMatch = [];
|
let ipsToMatch = [];
|
||||||
if (res.metadata.interfaces) {
|
if (res.metadata.interfaces) {
|
||||||
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||||
}
|
}
|
||||||
|
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||||
if (res.metadata.address) {
|
if (res.metadata.address) {
|
||||||
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||||
}
|
}
|
||||||
|
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||||
|
|
||||||
if (!existing && ipsToMatch.length > 0) {
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
const allRes = await Resource.list();
|
|
||||||
existing = allRes.find(r => {
|
existing = allRes.find(r => {
|
||||||
if (!r.metadata) return false;
|
if (!r.metadata) return false;
|
||||||
|
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||||
if (r.metadata.address) {
|
if (r.metadata.address) {
|
||||||
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||||
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||||
@@ -46,14 +53,17 @@ class DiscoveryReconciler {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback matching by Slug or Name
|
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||||
if (!existing && (res.slug || res.name)) {
|
if (!existing && (res.slug || res.name)) {
|
||||||
const allRes = await Resource.list();
|
const inputName = normalizeHost(res.name || res.slug);
|
||||||
existing = allRes.find(r =>
|
existing = allRes.find(r => {
|
||||||
(res.slug && r.slug === res.slug) ||
|
if (res.slug && r.slug === res.slug) return true;
|
||||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
|
||||||
);
|
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
|
||||||
|
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
GOD_ADMIN,
|
||||||
|
} = require('../utils/groups');
|
||||||
|
|
||||||
|
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
|
||||||
|
// prefix, services/apps are stored bare. The group-model builders use these
|
||||||
|
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
|
||||||
|
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||||
|
expect(slugify('Web 01')).toBe('web-01');
|
||||||
|
expect(slugify('Main Office')).toBe('main-office');
|
||||||
|
expect(slugify('my_host')).toBe('my-host');
|
||||||
|
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||||
|
expect(slugify('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group cn builders', () => {
|
||||||
|
test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => {
|
||||||
|
expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
|
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access');
|
||||||
|
});
|
||||||
|
test('aggregate uses the plural kind', () => {
|
||||||
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
|
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||||
|
});
|
||||||
|
test('site super admin + everyone', () => {
|
||||||
|
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
|
});
|
||||||
|
test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => {
|
||||||
|
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
|
||||||
|
// site (`site_local` -> `site-local`) would corrupt the delimiter.
|
||||||
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
|
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||||
|
expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||||
|
});
|
||||||
|
test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => {
|
||||||
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('levels', () => {
|
||||||
|
test('admin/access known; capabilities opaque', () => {
|
||||||
|
expect(isKnownLevel('admin')).toBe(true);
|
||||||
|
expect(isKnownLevel('access')).toBe(true);
|
||||||
|
expect(isKnownLevel('reboot')).toBe(false);
|
||||||
|
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(levelGrants('admin', 'access')).toBe(true);
|
||||||
|
expect(levelGrants('access', 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasPermission — inheritance', () => {
|
||||||
|
test('god_admin grants everything everywhere', () => {
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site super admin grants everything on its site, not other sites', () => {
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specific host group grants only that host', () => {
|
||||||
|
const cn = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
|
// aggregate capability
|
||||||
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
|
const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
|
const appAdmin = resourceGroupCns('main-office', 'emby', 'admin');
|
||||||
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-site isolation', () => {
|
||||||
|
const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,36 @@ jest.mock('redis', () => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
|
||||||
|
// can run without a database.
|
||||||
|
jest.mock('../models/shared_secret', () => ({
|
||||||
|
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/shared_secret_grant', () => ({
|
||||||
|
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/vault_app_token', () => {
|
||||||
|
const rows = [];
|
||||||
|
const VaultAppToken = {
|
||||||
|
_rows: rows,
|
||||||
|
list: jest.fn(async () => rows),
|
||||||
|
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
|
||||||
|
create: jest.fn(async (data) => {
|
||||||
|
const row = {
|
||||||
|
...data,
|
||||||
|
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
|
||||||
|
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { VaultAppToken };
|
||||||
|
});
|
||||||
|
|
||||||
const baoConf = require('@simpleworkjs/bao-conf');
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
const vaultBroker = require('../utils/vault_broker');
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
describe('vault_broker admin policy', () => {
|
describe('vault_broker admin policy', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -29,8 +57,8 @@ describe('vault_broker admin policy', () => {
|
|||||||
return { status: 404, text: async () => '' };
|
return { status: 404, text: async () => '' };
|
||||||
}
|
}
|
||||||
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
|
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
|
||||||
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }');
|
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }');
|
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
return { status: 204, ok: true };
|
return { status: 204, ok: true };
|
||||||
}
|
}
|
||||||
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
|
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
|
||||||
@@ -49,3 +77,128 @@ describe('vault_broker admin policy', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app token lifecycle (accessor storage + renewal)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
baoConf.request.mockReset();
|
||||||
|
VaultAppToken._rows.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
|
||||||
|
baoConf.request.mockImplementation(async (method, path, body) => {
|
||||||
|
if (path.startsWith('sys/policies/acl/')) {
|
||||||
|
if (method === 'GET') return { status: 404, text: async () => '' };
|
||||||
|
return { status: 204, ok: true };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/create/sso-app') {
|
||||||
|
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/renew-accessor') {
|
||||||
|
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/revoke-accessor') {
|
||||||
|
return { ok: true, status: 204, text: async () => '' };
|
||||||
|
}
|
||||||
|
return { status: 200, ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
|
||||||
|
mockBao({ mintAccessor: 'acc-old' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
|
||||||
|
|
||||||
|
mockBao({ mintAccessor: 'acc-new' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
|
||||||
|
mockBao();
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
VaultAppToken._rows[0].lastRenewedAt = 0;
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
|
||||||
|
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens records the failure on the row without throwing', async () => {
|
||||||
|
mockBao({ renewOk: false });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
|
||||||
|
// This exists because the proxy once shipped with a hook shape the installed
|
||||||
|
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
|
||||||
|
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
|
||||||
|
// request 403'd. A unit test on options can't catch that — only a wire test can.
|
||||||
|
describe('vaultProxy wire behavior', () => {
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
let target; // fake OpenBao
|
||||||
|
let seen; // last request the fake OpenBao received
|
||||||
|
let app; // sso app fragment: scopeGuard stub + vaultProxy
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
target = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
seen = { method: req.method, url: req.url, headers: req.headers, body };
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
target.listen(0, '127.0.0.1', () => {
|
||||||
|
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
|
||||||
|
jest.resetModules();
|
||||||
|
const broker = require('../utils/vault_broker');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(() => target.close(done));
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(path, opts = {}) {
|
||||||
|
const port = server.address().port;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${path}`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
|
||||||
|
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
|
||||||
|
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(seen.headers['auth-token']).toBeUndefined();
|
||||||
|
expect(seen.headers['authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
|
||||||
|
const res = await call('/api/vault/secret/data/users/alice/foo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
|
||||||
|
body: JSON.stringify({ data: { hello: 'world' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.method).toBe('POST');
|
||||||
|
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Theta42 group & permission model.
|
||||||
|
//
|
||||||
|
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||||
|
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||||
|
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||||
|
// uses the singular (host/app).
|
||||||
|
//
|
||||||
|
// god_admin global — everything, everywhere
|
||||||
|
// {site}_super_admin everything on the site
|
||||||
|
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||||
|
// {site}_hosts_<level>
|
||||||
|
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||||
|
// {site}_apps_<level> ... on ALL apps at the site
|
||||||
|
// {site}_app_<slug>_<level> ... on ONE app
|
||||||
|
// {site}_everyone / everyone meta groups (implicit membership)
|
||||||
|
//
|
||||||
|
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||||
|
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||||
|
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||||
|
//
|
||||||
|
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||||
|
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||||
|
|
||||||
|
const GOD_ADMIN = 'god_admin';
|
||||||
|
const KNOWN_LEVELS = ['admin', 'access'];
|
||||||
|
const KINDS = ['host', 'app'];
|
||||||
|
|
||||||
|
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||||
|
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a kind (host/app) — throw on anything else.
|
||||||
|
function assertKind(kind) {
|
||||||
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_{slug}_{level} — the per-resource group for one resource.
|
||||||
|
//
|
||||||
|
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
|
||||||
|
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
|
||||||
|
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
|
||||||
|
// yields `site_local_host_theta-env_access`. Services are stored without a
|
||||||
|
// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the
|
||||||
|
// convention the auto-provisioner, the resolver, and the access-request tests
|
||||||
|
// all share -- re-slugifying or inserting a kind would double the delimiter.
|
||||||
|
function resourceGroupCns(site, slug, level) {
|
||||||
|
return `${site}_${slug}_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
|
function aggregateGroupCns(site, kind, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}s_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_super_admin
|
||||||
|
function siteSuperAdminCns(site) {
|
||||||
|
return `${site}_super_admin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_everyone
|
||||||
|
function siteEveryoneCns(site) {
|
||||||
|
return `${site}_everyone`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `level` is a known admin/access level (not an opaque capability).
|
||||||
|
function isKnownLevel(level) {
|
||||||
|
return KNOWN_LEVELS.includes(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if holding `level` grants `wanted` (admin implies access).
|
||||||
|
function levelGrants(level, wanted) {
|
||||||
|
if (level === wanted) return true;
|
||||||
|
return level === 'admin' && wanted === 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||||
|
// `level` on a resource. Applies the inheritance lattice:
|
||||||
|
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||||
|
//
|
||||||
|
// memberOf: array of group cns the user is a member of.
|
||||||
|
// resource: { site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
//
|
||||||
|
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||||
|
// are resource-level grants, resolved by the caller against the resource's own
|
||||||
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
|
// the user's membership only.
|
||||||
|
function hasPermission(memberOf, resource, level) {
|
||||||
|
// `site` and `slug` are used verbatim (resource slugs may carry a kind prefix,
|
||||||
|
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
|
||||||
|
const site = resource && resource.site;
|
||||||
|
const kind = resource && resource.kind;
|
||||||
|
const slug = resource && resource.slug;
|
||||||
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
|
if (set.has(siteSuperAdminCns(site))) return true;
|
||||||
|
|
||||||
|
if (isKnownLevel(level)) {
|
||||||
|
// admin / access
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
||||||
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GOD_ADMIN,
|
||||||
|
KNOWN_LEVELS,
|
||||||
|
KINDS,
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
};
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
const groups = require('./groups');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
// The group nested into every resource's _admin group by api_directory_admin
|
||||||
|
// (cross-resource super-admin administration). This is `god_admin` -- the global
|
||||||
|
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
|
||||||
|
// It used to be the legacy `app_super_admin`, which existed while god_admin
|
||||||
|
// didn't; now that god_admin is created at boot, the provisioning nests it.
|
||||||
|
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
|
||||||
|
// the migration, so an existing deployment isn't stripped of rights until it's
|
||||||
|
// rebuilt.
|
||||||
|
const SUPER_ADMIN_GROUP = 'god_admin';
|
||||||
|
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
// True if the user (by resolved member cns) is a global god/super admin.
|
||||||
|
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
|
||||||
|
async function isSuperAdmin(memberOfCns) {
|
||||||
|
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||||
|
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let byGroup = async function(user, checkGroups, ownerOf){
|
||||||
// Membership is resolved once, transitively: a user placed in an admin group
|
// 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 +34,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 +59,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,10 +41,8 @@ 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']},
|
||||||
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', 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: '/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']},
|
||||||
|
|||||||
+241
-63
@@ -4,15 +4,25 @@
|
|||||||
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||||
// `sso-broker` token role created by theta-env/setup.sh.
|
// `sso-broker` token role created by theta-env/setup.sh.
|
||||||
//
|
//
|
||||||
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||||
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
|
||||||
// secret/* admin UI sessions (sso-admin policy)
|
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||||
|
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
|
||||||
|
// secret/* admin UI sessions (sso-admin policy)
|
||||||
//
|
//
|
||||||
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
||||||
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
||||||
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
||||||
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
||||||
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
||||||
|
//
|
||||||
|
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
|
||||||
|
// live at token use (only the SET of policy names on a token is fixed at mint),
|
||||||
|
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
|
||||||
|
// — cached or freshly minted. That way a stale cached token immediately gains
|
||||||
|
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
|
||||||
|
// an existing grantee token with no re-mint. The Redis cache only short-circuits
|
||||||
|
// token MINTING, never policy reconciliation.
|
||||||
|
|
||||||
const baoConf = require('@simpleworkjs/bao-conf');
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
const { createClient } = require('redis');
|
const { createClient } = require('redis');
|
||||||
@@ -20,6 +30,9 @@ const express = require('express');
|
|||||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const permission = require('./permission');
|
const permission = require('./permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
const ROLE = 'sso-broker';
|
const ROLE = 'sso-broker';
|
||||||
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||||
@@ -54,52 +67,86 @@ async function bao(method, path, body) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
|
||||||
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
// content and only PUT when it differs. `bao policy write` is an idempotent
|
||||||
// a list grant on a directory path) propagate on the next vault-page visit
|
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
||||||
// without an operator re-running setup.sh. Skipping on an existing policy
|
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||||
// would strand the old, narrower HCL forever.
|
|
||||||
async function ensurePolicy(name, hcl) {
|
async function ensurePolicy(name, hcl) {
|
||||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
if (existing.status !== 200 && existing.status !== 404) {
|
if (existing.status !== 200 && existing.status !== 404) {
|
||||||
const t = await existing.text().catch(() => '');
|
const t = await existing.text().catch(() => '');
|
||||||
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
}
|
}
|
||||||
|
if (existing.status === 200) {
|
||||||
|
const body = await existing.json().catch(() => null);
|
||||||
|
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
||||||
|
}
|
||||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint a token through the sso-broker role with the given policies. Returns
|
// Mint a token through a token role with the given policies. Returns
|
||||||
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
|
||||||
async function mintToken(policies) {
|
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
|
||||||
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
// from cache) and sso-app (768h period — long-lived external-app credentials,
|
||||||
|
// kept alive via their stored accessor by the renewal loop below).
|
||||||
|
async function mintToken(policies, role = ROLE) {
|
||||||
|
const res = await bao('POST', `auth/token/create/${role}`, { policies });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const token = json && json.auth && json.auth.client_token;
|
const token = json && json.auth && json.auth.client_token;
|
||||||
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||||
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||||
return { token, ttl };
|
return { token, accessor: json.auth.accessor, ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared-secret policy rules ───────────────────────────────────────────────
|
||||||
|
// Returns the HCL rules granting `read` on every shared secret the given
|
||||||
|
// grantee (a user uid or an app name) has been granted. Enforcement is
|
||||||
|
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
|
||||||
|
// effect for the grantee's existing token immediately (no re-mint).
|
||||||
|
async function sharedPolicyRules(granteeType, granteeId) {
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
|
||||||
|
if (!grants.length) return '';
|
||||||
|
const secretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const secrets = secretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
|
||||||
|
const byId = new Map(secrets.map(s => [s.id, s]));
|
||||||
|
const rules = [];
|
||||||
|
for (const g of grants) {
|
||||||
|
const sec = byId.get(g.secretId);
|
||||||
|
if (!sec) continue;
|
||||||
|
const p = sec.path(); // shared/<ownerUid>/<slug>
|
||||||
|
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
|
||||||
|
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
|
||||||
|
}
|
||||||
|
return rules.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-user token ──────────────────────────────────────────────────────────
|
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||||
function userPolicyHcl(uid) {
|
async function userPolicyHcl(uid) {
|
||||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
const granted = await sharedPolicyRules('user', uid);
|
||||||
// into a policy path, so reject anything but a safe charset.
|
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// directory itself, so without it the /vault secrets list 403s.
|
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
|
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
|
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
|
||||||
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
// (compare-and-skip) before the cache is consulted, so a cached token can never
|
||||||
|
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
|
||||||
|
// when the cache entry expires (a little before the token's own TTL).
|
||||||
async function getOrCreateUserToken(uid) {
|
async function getOrCreateUserToken(uid) {
|
||||||
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
||||||
|
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
|
||||||
const cacheKey = `vault_token:${uid}`;
|
const cacheKey = `vault_token:${uid}`;
|
||||||
const cached = await cacheGet(cacheKey);
|
const cached = await cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
|
||||||
const { token, ttl } = await mintToken([`user-${uid}`]);
|
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
return token;
|
return token;
|
||||||
@@ -107,47 +154,164 @@ async function getOrCreateUserToken(uid) {
|
|||||||
|
|
||||||
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||||
function adminPolicyHcl() {
|
function adminPolicyHcl() {
|
||||||
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
|
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
|
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
// paths but NOT the root itself, so without it the /vault secrets list 403s.
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOrCreateAdminToken(uid) {
|
async function getOrCreateAdminToken(uid) {
|
||||||
|
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||||
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||||
const cached = await cacheGet(cacheKey);
|
const cached = await cacheGet(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
await ensurePolicy('sso-admin', adminPolicyHcl());
|
|
||||||
const { token, ttl } = await mintToken(['sso-admin']);
|
const { token, ttl } = await mintToken(['sso-admin']);
|
||||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
function appPolicyHcl(name) {
|
async function appPolicyHcl(name) {
|
||||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
const granted = await sharedPolicyRules('app', name);
|
||||||
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||||
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
||||||
// a later compromise of an admin session cannot recover previously-minted app
|
// a later compromise of an admin session cannot recover previously-minted app
|
||||||
// tokens. The caller must record it in the external app immediately.
|
// tokens. The caller must record it in the external app immediately. Later
|
||||||
async function mintAppToken(name) {
|
// grants to the app edit app-<name> policy content (live-applied to this token).
|
||||||
|
//
|
||||||
|
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
|
||||||
|
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
|
||||||
|
// token alive and lets a re-mint revoke the app's previous token so exactly
|
||||||
|
// one credential per app is ever live.
|
||||||
|
async function mintAppToken(name, actorUid) {
|
||||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||||
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||||
}
|
}
|
||||||
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
|
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
|
||||||
const { token, ttl } = await mintToken([`app-${name}`]);
|
// App tokens are long-lived credentials: mint via the sso-app role (768h
|
||||||
|
// period) so a renewal inside every 32-day window keeps them alive forever.
|
||||||
|
// Fall back to the broker's own 24h role on deployments whose setup.sh
|
||||||
|
// predates the sso-app role (re-running setup.sh creates it).
|
||||||
|
let minted;
|
||||||
|
try {
|
||||||
|
minted = await mintToken([`app-${name}`], 'sso-app');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
|
||||||
|
minted = await mintToken([`app-${name}`]);
|
||||||
|
}
|
||||||
|
const { token, accessor, ttl } = minted;
|
||||||
|
// Replace the app's accessor row; revoke the superseded token (best-effort —
|
||||||
|
// it may already be expired) so re-minting never leaves a zombie credential.
|
||||||
|
try {
|
||||||
|
const existing = await VaultAppToken.getByName(name);
|
||||||
|
if (existing) {
|
||||||
|
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
|
||||||
|
await existing.delete();
|
||||||
|
}
|
||||||
|
if (accessor) {
|
||||||
|
await VaultAppToken.create({
|
||||||
|
name, accessor,
|
||||||
|
lastRenewedAt: Date.now(),
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Accessor bookkeeping must never block handing the token out; without a
|
||||||
|
// row the token simply isn't auto-renewed (it still lives one full period).
|
||||||
|
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
|
||||||
|
}
|
||||||
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── App-token renewal loop ──────────────────────────────────────────────────
|
||||||
|
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
|
||||||
|
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
|
||||||
|
// far inside both possible periods (24h fallback and 768h), so a downstream
|
||||||
|
// app's token stays valid for as long as sso is running. Failures are recorded
|
||||||
|
// on the row (visible to admins in the DB / future UI) and never throw.
|
||||||
|
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
|
||||||
|
let renewTimer;
|
||||||
|
|
||||||
|
async function renewAppTokens() {
|
||||||
|
let rows;
|
||||||
|
try { rows = await VaultAppToken.list(); }
|
||||||
|
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
|
||||||
|
if (res.ok) {
|
||||||
|
await row.update({ lastRenewedAt: Date.now(), lastError: null });
|
||||||
|
} else {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
// 400 "invalid accessor" = token expired or was revoked out-of-band;
|
||||||
|
// keep the row + error so the admin can see the app needs a re-mint.
|
||||||
|
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
|
||||||
|
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
|
||||||
|
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the loop (idempotent). unref() so an open handle never blocks exit.
|
||||||
|
function startAppTokenRenewal() {
|
||||||
|
if (renewTimer) return renewTimer;
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
|
||||||
|
renewTimer = setInterval(() => {
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
|
||||||
|
}, RENEW_INTERVAL_MS);
|
||||||
|
if (renewTimer.unref) renewTimer.unref();
|
||||||
|
return renewTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grant / revoke shared-secret access ─────────────────────────────────────
|
||||||
|
// Creating a grant writes the DB row and then edits the grantee's policy content
|
||||||
|
// to add read on the shared path; revoking removes both. Because OpenBao parses
|
||||||
|
// policy content live, the change applies to the grantee's existing token
|
||||||
|
// immediately — no token re-mint, no cache invalidation needed.
|
||||||
|
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.create({
|
||||||
|
secretId, granteeType, granteeId, capability: 'read',
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
updated_by: actorUid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSharedSecret(grantId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.get(grantId);
|
||||||
|
if (!grant) return null;
|
||||||
|
const { granteeType, granteeId } = grant;
|
||||||
|
await grant.delete();
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute and rewrite a grantee's policy content after a grant/revoke.
|
||||||
|
async function reconcileGrantee(granteeType, granteeId) {
|
||||||
|
if (granteeType === 'user') {
|
||||||
|
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
|
||||||
|
} else if (granteeType === 'app') {
|
||||||
|
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
|
||||||
|
} else {
|
||||||
|
throw new Error(`invalid granteeType: ${granteeType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||||
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||||
// nothing). The guard mints a server-side token for the user (per-user or
|
// nothing). The guard mints a server-side token for the user (per-user or
|
||||||
@@ -156,11 +320,12 @@ async function mintAppToken(name) {
|
|||||||
// client's sso auth headers so OpenBao never sees them.
|
// client's sso auth headers so OpenBao never sees them.
|
||||||
|
|
||||||
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
const ADMIN_GROUP = 'app_sso_admin';
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
async function isAdmin(user) {
|
async function isAdmin(user) {
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(user, [ADMIN_GROUP]);
|
await permission.byGroup(user, ADMIN_GROUPS);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -189,17 +354,13 @@ async function scopeGuard(req, res, next) {
|
|||||||
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defense-in-depth: confirm the requested path is within the subject's
|
|
||||||
// namespace. Admins roam all of secret/; users are confined to
|
|
||||||
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
|
||||||
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
|
||||||
// 403 instead of an opaque OpenBao denial.)
|
|
||||||
const norm = normalizeVaultPath(req.path);
|
const norm = normalizeVaultPath(req.path);
|
||||||
if (norm === null) {
|
if (norm === null) {
|
||||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||||
}
|
}
|
||||||
const base = `/secret/users/${uid}`;
|
const userBase = `/secret/users/${uid}`;
|
||||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
const sharedBase = `/secret/shared`;
|
||||||
|
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return res.status(403).json({ error: 'path outside your vault namespace' });
|
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||||
}
|
}
|
||||||
@@ -214,15 +375,20 @@ function vaultProxy() {
|
|||||||
target: VAULT_ADDR,
|
target: VAULT_ADDR,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { '^/api/vault': '/v1' },
|
pathRewrite: { '^/api/vault': '/v1' },
|
||||||
on: {
|
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
|
||||||
proxyReq(proxyReq, req, res, options) {
|
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
|
||||||
fixRequestBody(proxyReq, req, res, options);
|
// which shipped this proxy with NO token injection — every /api/vault
|
||||||
// Inject ONLY the server-minted scoped token; strip the client's
|
// call reached OpenBao unauthenticated and 403'd.
|
||||||
// sso session/api auth so it never reaches OpenBao.
|
onProxyReq(proxyReq, req, res, options) {
|
||||||
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
// Header ops MUST precede fixRequestBody: it write()s the parsed body
|
||||||
proxyReq.removeHeader('auth-token');
|
// onto proxyReq, which flushes headers — setHeader after that throws
|
||||||
proxyReq.removeHeader('authorization');
|
// (swallowed upstream), silently dropping the token on every write.
|
||||||
},
|
// Inject ONLY the server-minted scoped token; strip the client's
|
||||||
|
// sso session/api auth so it never reaches OpenBao.
|
||||||
|
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||||
|
proxyReq.removeHeader('auth-token');
|
||||||
|
proxyReq.removeHeader('authorization');
|
||||||
|
fixRequestBody(proxyReq, req, res, options);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -236,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
|
|||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
const name = (req.body && req.body.name || '').trim();
|
const name = (req.body && req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
const result = await mintAppToken(name);
|
const result = await mintAppToken(name, req.user && req.user.uid);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
@@ -252,4 +418,16 @@ module.exports = {
|
|||||||
scopeGuard,
|
scopeGuard,
|
||||||
vaultProxy,
|
vaultProxy,
|
||||||
mintAppRouter,
|
mintAppRouter,
|
||||||
};
|
// app-token lifecycle
|
||||||
|
renewAppTokens,
|
||||||
|
startAppTokenRenewal,
|
||||||
|
VaultAppToken,
|
||||||
|
// sharing
|
||||||
|
SharedSecret,
|
||||||
|
SharedSecretGrant,
|
||||||
|
userPolicyHcl,
|
||||||
|
appPolicyHcl,
|
||||||
|
grantSharedSecret,
|
||||||
|
revokeSharedSecret,
|
||||||
|
reconcileGrantee,
|
||||||
|
};
|
||||||
|
|||||||
+286
-256
@@ -1,11 +1,16 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||||
|
|
||||||
|
var messagingTypes = {};
|
||||||
|
var messagingPlugins = [];
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadConf();
|
loadConf();
|
||||||
loadProxyConf();
|
loadProxyConf();
|
||||||
loadTos();
|
loadTos();
|
||||||
|
loadMessagingPlugins();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConf() {
|
async function loadConf() {
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
|
|
||||||
async function saveConf() {
|
async function saveConf() {
|
||||||
const btn = $('#btn-save');
|
const btn = $('#btn-save');
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
smtp: {
|
smtp: {
|
||||||
@@ -72,11 +77,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.api.post('conf', payload);
|
await app.api.post('conf', payload);
|
||||||
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
app.messages.toast('Configuration saved successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +92,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $inputGroup = $('#test-email-to').closest('.input-group');
|
const btn = $('#btn-test-email');
|
||||||
const btn = $inputGroup.find('button');
|
|
||||||
const originalHtml = btn.html();
|
const originalHtml = btn.html();
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First save the SMTP config, then send test email
|
|
||||||
const payload = {
|
const payload = {
|
||||||
smtp: {
|
smtp: {
|
||||||
host: $('#smtp-host').val(),
|
host: $('#smtp-host').val(),
|
||||||
@@ -104,11 +107,7 @@
|
|||||||
secure: $('#smtp-secure').is(':checked')
|
secure: $('#smtp-secure').is(':checked')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save config first
|
|
||||||
await app.api.post('conf', payload);
|
await app.api.post('conf', payload);
|
||||||
|
|
||||||
// Then send test email
|
|
||||||
const result = await app.api.post('conf/test-email', { to });
|
const result = await app.api.post('conf/test-email', { to });
|
||||||
app.messages.toast(result.message || 'Test email sent!', 'success');
|
app.messages.toast(result.message || 'Test email sent!', 'success');
|
||||||
$('#test-email-to').val('');
|
$('#test-email-to').val('');
|
||||||
@@ -126,13 +125,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $inputGroup = $('#test-sms-to').closest('.input-group');
|
const btn = $('#btn-test-sms');
|
||||||
const btn = $inputGroup.find('button');
|
|
||||||
const originalHtml = btn.html();
|
const originalHtml = btn.html();
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First save the VoIP.ms config, then send test SMS
|
|
||||||
const payload = {
|
const payload = {
|
||||||
voipms: {
|
voipms: {
|
||||||
username: $('#voipms-username').val(),
|
username: $('#voipms-username').val(),
|
||||||
@@ -140,11 +137,7 @@
|
|||||||
password: $('#voipms-password').val()
|
password: $('#voipms-password').val()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save config first
|
|
||||||
await app.api.post('conf', payload);
|
await app.api.post('conf', payload);
|
||||||
|
|
||||||
// Then send test SMS
|
|
||||||
const result = await app.api.post('conf/test-sms', { to });
|
const result = await app.api.post('conf/test-sms', { to });
|
||||||
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
||||||
$('#test-sms-to').val('');
|
$('#test-sms-to').val('');
|
||||||
@@ -182,7 +175,7 @@
|
|||||||
|
|
||||||
async function saveProxyConf() {
|
async function saveProxyConf() {
|
||||||
const btn = $('#btn-save-proxy');
|
const btn = $('#btn-save-proxy');
|
||||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
oidc: {
|
oidc: {
|
||||||
@@ -201,22 +194,18 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets');
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Terms of Service editor ──────────────────────────────────────────
|
|
||||||
// Moved here from the admin Overview dashboard — it's a configuration
|
|
||||||
// control, so it belongs on the System Configuration page. The API is
|
|
||||||
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
|
|
||||||
// matches this page's gate). app.tos.get/update are the shared frontend
|
|
||||||
// helpers (@simpleworkjs/frontend).
|
|
||||||
async function loadTos() {
|
async function loadTos() {
|
||||||
try {
|
try {
|
||||||
const tos = await app.tos.get();
|
const tos = await app.tos.get();
|
||||||
document.getElementById('tos-content').value = tos.content;
|
if (tos && tos.content) {
|
||||||
document.getElementById('tos-meta').textContent =
|
document.getElementById('tos-content').value = tos.content;
|
||||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
document.getElementById('tos-meta').textContent =
|
||||||
|
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load ToS:', e);
|
console.error('Failed to load ToS:', e);
|
||||||
}
|
}
|
||||||
@@ -248,246 +237,287 @@
|
|||||||
loadTos();
|
loadTos();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Messaging Plugins ──────────────────────────────────────────────
|
||||||
|
function loadMessagingPlugins() {
|
||||||
|
app.api.get('plugins/types', function(err, res) {
|
||||||
|
if (!err && res && res.results) {
|
||||||
|
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
|
||||||
|
}
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if (err) return;
|
||||||
|
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
|
||||||
|
renderMessagingPlugins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessagingPlugins() {
|
||||||
|
const $list = $('#messaging-plugins-list').empty();
|
||||||
|
if (messagingPlugins.length === 0) {
|
||||||
|
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messagingPlugins.forEach(p => {
|
||||||
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
|
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||||
|
const card = `
|
||||||
|
<div class="card mb-3 border shadow-sm">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||||
|
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id, state) {
|
||||||
|
const endpoint = state ? 'load' : 'unload';
|
||||||
|
try {
|
||||||
|
await app.api.post(`plugins/${id}/${endpoint}`, {});
|
||||||
|
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id) {
|
||||||
|
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete(`plugins/${id}`);
|
||||||
|
app.messages.toast('Plugin deleted', 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container mt-4">
|
||||||
<div class="row mb-4">
|
<div class="row">
|
||||||
<div class="col d-flex justify-content-between align-items-center">
|
<div class="col-12">
|
||||||
<div>
|
<div class="card shadow">
|
||||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
|
||||||
<p class="text-muted mb-0">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
|
||||||
settings. These are stored securely in OpenBao and take effect immediately.
|
<li class="nav-item" role="presentation">
|
||||||
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
|
||||||
are masked — leave them unchanged to keep the stored value.
|
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
|
||||||
</p>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
<div>
|
<li class="nav-item" role="presentation">
|
||||||
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
|
||||||
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
|
||||||
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
|
||||||
<li class="nav-item" role="presentation">
|
</button>
|
||||||
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button>
|
</li>
|
||||||
</li>
|
<li class="nav-item" role="presentation">
|
||||||
<li class="nav-item" role="presentation">
|
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
|
||||||
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
|
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||||
</li>
|
</button>
|
||||||
<li class="nav-item" role="presentation">
|
</li>
|
||||||
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
|
<li class="nav-item" role="presentation">
|
||||||
</li>
|
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||||
<li class="nav-item" role="presentation">
|
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
</ul>
|
||||||
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
|
<div>
|
||||||
</li>
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
|
||||||
</ul>
|
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
|
||||||
|
</div>
|
||||||
<div class="tab-content" id="confTabsContent">
|
|
||||||
<!-- SMTP Tab -->
|
|
||||||
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
|
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
<div class="card-body p-4">
|
||||||
<label class="form-label">Host</label>
|
<div class="tab-content" id="confTabContent">
|
||||||
<input type="text" class="form-control" id="smtp-host">
|
|
||||||
</div>
|
<!-- OAuth & JWT Tab -->
|
||||||
<div class="mb-3">
|
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
|
||||||
<label class="form-label">Port</label>
|
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
|
||||||
<input type="number" class="form-control" id="smtp-port">
|
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
<label class="form-label">User</label>
|
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
|
||||||
<input type="text" class="form-control" id="smtp-user">
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label fw-semibold">JWT Secret</label>
|
||||||
<label class="form-label">Password</label>
|
<div class="input-group">
|
||||||
<div class="input-group">
|
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||||
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
</div>
|
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
|
||||||
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
</div>
|
||||||
<hr class="my-4">
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Send Test SMS</label>
|
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
|
||||||
<div class="input-group">
|
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
|
||||||
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
</div>
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
|
<div class="col-md-6 mb-3">
|
||||||
<i class="fas fa-paper-plane"></i> Send Test SMS
|
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
|
||||||
</button>
|
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="my-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Send Test SMS</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
|
|
||||||
<i class="fas fa-paper-plane"></i> Send Test SMS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">From Address</label>
|
|
||||||
<input type="text" class="form-control" id="smtp-from">
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
|
||||||
<label class="form-check-label">Use Secure (TLS)</label>
|
|
||||||
</div>
|
|
||||||
<hr class="my-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Send Test Email</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
|
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
|
|
||||||
<i class="fas fa-paper-plane"></i> Send Test Email
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OAuth Tab -->
|
<!-- SMTP Tab -->
|
||||||
<div class="tab-pane fade" id="oauth" role="tabpanel">
|
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
|
||||||
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
<div class="row">
|
||||||
</div>
|
<div class="col-md-8 mb-3">
|
||||||
<div class="card-body">
|
<label class="form-label fw-semibold">SMTP Host</label>
|
||||||
<div class="mb-3">
|
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
|
||||||
<label class="form-label">Issuer URL</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="oauth-issuer">
|
<div class="col-md-4 mb-3">
|
||||||
</div>
|
<label class="form-label fw-semibold">Port</label>
|
||||||
<div class="mb-3">
|
<input type="number" class="form-control" id="smtp-port" placeholder="587">
|
||||||
<label class="form-label">JWT Secret</label>
|
</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
<div class="row">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">User</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-user">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">From Address</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||||
|
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
|
||||||
|
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Saves current SMTP config and sends a test message.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
|
||||||
<input type="number" class="form-control" id="oauth-token-access">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Refresh Token Lifetime (seconds)</label>
|
|
||||||
<input type="number" class="form-control" id="oauth-token-refresh">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SMS Tab -->
|
<!-- SMS & Messaging Tab -->
|
||||||
<div class="tab-pane fade" id="sms" role="tabpanel">
|
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
|
||||||
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
<div class="row">
|
||||||
</div>
|
<div class="col-md-6 mb-3">
|
||||||
<div class="card-body">
|
<label class="form-label fw-semibold">API Username</label>
|
||||||
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
<input type="text" class="form-control" id="voipms-username">
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label class="form-label">API Username</label>
|
<div class="col-md-6 mb-3">
|
||||||
<input type="text" class="form-control" id="voipms-username">
|
<label class="form-label fw-semibold">DID Sender Number</label>
|
||||||
</div>
|
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label class="form-label">DID (sender number)</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
<div class="mb-3">
|
||||||
</div>
|
<label class="form-label fw-semibold">API Password</label>
|
||||||
<div class="mb-3">
|
<div class="input-group">
|
||||||
<label class="form-label">API Password</label>
|
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||||
<div class="input-group">
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||||
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border mb-4">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
||||||
|
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="messaging-plugins-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
|
||||||
<hr class="my-4">
|
<!-- Proxy Secrets Tab -->
|
||||||
<div class="mb-3">
|
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
|
||||||
<label class="form-label">Send Test SMS</label>
|
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
|
||||||
<div class="input-group">
|
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
|
||||||
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
|
||||||
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
|
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
|
||||||
<i class="fas fa-paper-plane"></i> Send Test SMS
|
<div class="mb-3">
|
||||||
</button>
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
|
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client ID</label>
|
||||||
|
<input type="text" class="form-control" id="proxy-client-id">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Proxy Bind Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proxy Secrets Tab -->
|
<!-- Terms of Service Tab -->
|
||||||
<div class="tab-pane fade" id="proxy" role="tabpanel">
|
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
|
||||||
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
|
<span class="small text-muted" id="tos-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="mb-3">
|
||||||
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
|
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
|
||||||
|
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
|
||||||
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="form-check mb-4">
|
||||||
<label class="form-label">Issuer URL</label>
|
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
|
||||||
<label class="form-label">Client ID</label>
|
<div id="tos-result" style="display:none" class="mt-3"></div>
|
||||||
<input type="text" class="form-control" id="proxy-client-id">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Client Secret</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h6 class="mt-4 mb-2">LDAP Integration</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Bind Password</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Password for the Proxy's LDAP service account.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ToS Tab -->
|
|
||||||
<div class="tab-pane fade" id="tos" role="tabpanel">
|
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
|
||||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
|
|
||||||
<small class="text-muted" id="tos-meta"></small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
|
||||||
<textarea class="form-control" id="tos-content" rows="8"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
|
||||||
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
|
|
||||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+359
-12
@@ -13,7 +13,12 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
||||||
<i class="fa-solid fa-network-wired"></i> Discovery
|
<i class="fa-solid fa-network-wired"></i> Discovered Inventory
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||||
|
<i class="fa-solid fa-plug"></i> Discovery Plugins
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -25,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;">
|
||||||
@@ -67,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}}
|
||||||
@@ -187,6 +194,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Discovery Plugins Tab Pane -->
|
||||||
|
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||||
|
<div class="p-4 bg-white border-top">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
|
||||||
|
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
|
||||||
|
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="discovery-plugins-list" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,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>
|
||||||
|
|
||||||
@@ -454,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'); }}) : '',
|
||||||
@@ -499,24 +525,43 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
|
||||||
|
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
|
||||||
|
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
||||||
|
var agentsByHost = {};
|
||||||
|
var agentsByToken = {};
|
||||||
|
// True when the agent/nodes endpoint itself was unreachable (network, or an
|
||||||
|
// older app without the agent route). When set we cannot tell "this host has
|
||||||
|
// no agent" apart from "the agent service is down", so we must NOT paint every
|
||||||
|
// host red as if it lacked an agent.
|
||||||
|
var agentsUnavailable = false;
|
||||||
|
|
||||||
async function loadResources() {
|
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. Track
|
||||||
|
// whether the endpoint itself is reachable so host rows can tell "no
|
||||||
|
// agent on this host" from "agent service is down" (see attachAgentStatus).
|
||||||
|
app.api.get('agent/nodes')
|
||||||
|
.then(function(res){ agentsUnavailable = false; return res; })
|
||||||
|
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
accessSummary = (resAccess && resAccess.results) || {};
|
accessSummary = (resAccess && resAccess.results) || {};
|
||||||
resourcesById = {};
|
resourcesById = {};
|
||||||
|
|
||||||
for (const r of resResources.results) {
|
for (const r of resResources.results) {
|
||||||
r.metadata = r.metadata || {};
|
r.metadata = r.metadata || {};
|
||||||
resourcesById[r.id] = r;
|
resourcesById[r.id] = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
indexAgents((resAgents && resAgents.agents) || []);
|
||||||
|
|
||||||
allGroups = resGroups.results;
|
allGroups = resGroups.results;
|
||||||
allEdges = resEdges.results;
|
allEdges = resEdges.results;
|
||||||
@@ -550,6 +595,88 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
|
||||||
|
function indexAgents(agents) {
|
||||||
|
agentsByHost = {};
|
||||||
|
agentsByToken = {};
|
||||||
|
for (const a of agents || []) {
|
||||||
|
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
|
||||||
|
if (hn) agentsByHost[hn] = a;
|
||||||
|
if (a.token) agentsByToken[a.token] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
|
||||||
|
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
|
||||||
|
|
||||||
|
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
|
||||||
|
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
|
||||||
|
// stores the agent on resourcesById so the Metrics tab can find it.
|
||||||
|
function attachAgentStatus(n) {
|
||||||
|
n.isHost = true;
|
||||||
|
const name = (n.name || '').toLowerCase();
|
||||||
|
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
|
||||||
|
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
|
||||||
|
n.agent = a || null;
|
||||||
|
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||||
|
if (!a) {
|
||||||
|
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
|
||||||
|
// false red alarm across every host.
|
||||||
|
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
|
||||||
|
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
|
||||||
|
}
|
||||||
|
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
|
||||||
|
const t = a.telemetry || {};
|
||||||
|
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||||
|
n.agentColor = high ? '#ffc107' : '#198754';
|
||||||
|
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics tab body for the resource modal (snapshot of the joined agent).
|
||||||
|
function metricsTabHtml(agent) {
|
||||||
|
if (!agent) {
|
||||||
|
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
|
||||||
|
}
|
||||||
|
const d = agent.discovery || {};
|
||||||
|
const t = agent.telemetry || {};
|
||||||
|
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
|
||||||
|
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
|
||||||
|
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
|
||||||
|
return `<div class="p-3">
|
||||||
|
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
|
||||||
|
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
|
||||||
|
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
|
||||||
|
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
|
||||||
|
<div class="col-6">GPU <strong>${gpu}</strong></div>
|
||||||
|
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
|
||||||
|
</div>
|
||||||
|
<hr><h6>Discovery</h6>
|
||||||
|
<div class="row small text-muted">
|
||||||
|
<div class="col-6">OS: ${esc(d.os || '')}</div>
|
||||||
|
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
|
||||||
|
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
|
||||||
|
<div class="col-6">Location: ${esc(d.location || '')}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
|
||||||
|
async function refreshAgents() {
|
||||||
|
try {
|
||||||
|
const res = await app.api.get('agent/nodes');
|
||||||
|
indexAgents((res && res.agents) || []);
|
||||||
|
agentsUnavailable = false;
|
||||||
|
renderTable();
|
||||||
|
} catch (e) {
|
||||||
|
agentsUnavailable = true;
|
||||||
|
renderTable(); // re-render so dots flip to neutral, not stale green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
// "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
|
||||||
@@ -657,6 +784,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);
|
||||||
@@ -1188,6 +1316,7 @@
|
|||||||
allEdges.push(res.results);
|
allEdges.push(res.results);
|
||||||
refreshEdgesUI(resourceId);
|
refreshEdgesUI(resourceId);
|
||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
|
await loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
@@ -1199,6 +1328,7 @@
|
|||||||
await app.api.delete('directory-admin/edges/' + id);
|
await app.api.delete('directory-admin/edges/' + id);
|
||||||
allEdges = allEdges.filter(e => e.id !== id);
|
allEdges = allEdges.filter(e => e.id !== id);
|
||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
|
await loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||||
@@ -1238,9 +1368,10 @@
|
|||||||
function renderDiscoveryTable() {
|
function renderDiscoveryTable() {
|
||||||
const search = $('#discovery-search-filter').val().toLowerCase();
|
const search = $('#discovery-search-filter').val().toLowerCase();
|
||||||
const filtered = allDiscoveryResources.filter(r => {
|
const filtered = allDiscoveryResources.filter(r => {
|
||||||
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
const isManaged = !!(r.metadata && r.metadata.managed);
|
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
|
||||||
if(isManaged) return false;
|
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
|
||||||
|
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1493,7 +1624,7 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
app.modal.open({
|
app.modal.open({
|
||||||
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent',
|
title: 'Install Theta Agent',
|
||||||
bodyHtml: bodyHtml,
|
bodyHtml: bodyHtml,
|
||||||
size: 'lg'
|
size: 'lg'
|
||||||
});
|
});
|
||||||
@@ -1501,12 +1632,228 @@
|
|||||||
updateAgentCommands();
|
updateAgentCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
|
var discoveryPlugins = [];
|
||||||
// Scheduler tab here was its old home). Discovery inventory + the discovery
|
|
||||||
// results table remain on this page.
|
function loadDiscoveryPlugins() {
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if (err) return;
|
||||||
|
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
|
||||||
|
renderDiscoveryPlugins();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiscoveryPlugins() {
|
||||||
|
const $list = $('#discovery-plugins-list').empty();
|
||||||
|
if (discoveryPlugins.length === 0) {
|
||||||
|
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discoveryPlugins.forEach(p => {
|
||||||
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
|
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||||
|
const card = `
|
||||||
|
<div class="card mb-3 border shadow-sm">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||||
|
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||||
|
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDiscoveryPlugin(id, state) {
|
||||||
|
const endpoint = state ? 'load' : 'unload';
|
||||||
|
try {
|
||||||
|
await app.api.post(`plugins/${id}/${endpoint}`, {});
|
||||||
|
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiscoveryPluginNow(id) {
|
||||||
|
try {
|
||||||
|
await app.api.post(`plugins/${id}/run`, {});
|
||||||
|
app.messages.toast('Enqueued discovery plugin run', 'success');
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error running plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoveryPluginTypes = [];
|
||||||
|
|
||||||
|
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
|
||||||
|
// Stored value is always a 5-field cron string; the dropdown picks a preset
|
||||||
|
// and "Custom…" reveals the raw input. Config fields are driven by each
|
||||||
|
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
|
||||||
|
// tokenId / tokenSecret) are collected at create time.
|
||||||
|
var DP_CRON_PRESETS = [
|
||||||
|
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||||
|
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||||
|
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||||
|
{ key: 'custom', label: 'Custom…', cron: null },
|
||||||
|
];
|
||||||
|
function dpCronKeyFor(cron) {
|
||||||
|
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||||
|
return m ? m.key : 'custom';
|
||||||
|
}
|
||||||
|
function dpCronSelectHtml(prefix, current) {
|
||||||
|
current = current || '0 * * * *';
|
||||||
|
var key = dpCronKeyFor(current);
|
||||||
|
var opts = DP_CRON_PRESETS.map(function(p){
|
||||||
|
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
|
||||||
|
}).join('');
|
||||||
|
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
|
||||||
|
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="dpOnCronChange(\'' + prefix + '\')">' + opts + '</select>' +
|
||||||
|
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + current + '"' + rawStyle + '>';
|
||||||
|
}
|
||||||
|
function dpOnCronChange(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
if (!sel || !raw) return;
|
||||||
|
if (sel.value === 'custom') { raw.style.display = ''; }
|
||||||
|
else {
|
||||||
|
raw.style.display = 'none';
|
||||||
|
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) raw.value = preset.cron;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dpCronFromForm(prefix) {
|
||||||
|
var sel = document.getElementById(prefix + 'cron-select');
|
||||||
|
if (sel && sel.value !== 'custom') {
|
||||||
|
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||||
|
if (preset) return preset.cron;
|
||||||
|
}
|
||||||
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
|
return (raw && raw.value.trim()) || '0 * * * *';
|
||||||
|
}
|
||||||
|
function dpConfigFormHtml(type, prefix) {
|
||||||
|
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||||
|
var schema = t && t.configSchema;
|
||||||
|
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||||
|
var html = '';
|
||||||
|
schema.forEach(function(f) {
|
||||||
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
|
var req = f.required ? ' required' : '';
|
||||||
|
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||||
|
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||||
|
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
|
||||||
|
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function dpCollectConfig(type, prefix) {
|
||||||
|
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||||
|
var schema = t && t.configSchema;
|
||||||
|
var out = {};
|
||||||
|
if (!schema) return out;
|
||||||
|
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function dpRenderFields() {
|
||||||
|
var type = document.getElementById('new-plugin-type').value;
|
||||||
|
document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewDiscoveryPluginModal() {
|
||||||
|
app.api.get('plugins/types', function(err, res) {
|
||||||
|
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||||
|
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
|
||||||
|
if (discoveryPluginTypes.length === 0) {
|
||||||
|
app.messages.toast('No discovery plugin types available', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
|
||||||
|
const bodyHtml = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Plugin Type</label>
|
||||||
|
<select id="new-plugin-type" class="form-select shadow-sm" onchange="dpRenderFields()">${options}</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Instance Name</label>
|
||||||
|
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
|
||||||
|
<div class="form-text">A slug is derived automatically from the name.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Schedule</label>
|
||||||
|
${dpCronSelectHtml('np-', '0 * * * *')}
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
|
||||||
|
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
|
||||||
|
</div>
|
||||||
|
<hr><h6 class="fw-bold">Configuration</h6><div id="new-plugin-config-fields">${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}</div>
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Configure New Discovery Plugin',
|
||||||
|
bodyHtml: bodyHtml,
|
||||||
|
size: 'lg'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNewDiscoveryPlugin() {
|
||||||
|
const type = $('#new-plugin-type').val();
|
||||||
|
const name = $('#new-plugin-name').val().trim();
|
||||||
|
const cron = dpCronFromForm('np-');
|
||||||
|
const enabled = $('#new-plugin-enabled').is(':checked');
|
||||||
|
const config = dpCollectConfig(type, 'np-');
|
||||||
|
|
||||||
|
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
||||||
|
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.api.post('plugins', {
|
||||||
|
pluginType: type,
|
||||||
|
name,
|
||||||
|
cron,
|
||||||
|
enabled,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
app.messages.toast('Discovery plugin created successfully!', 'success');
|
||||||
|
app.modal.close();
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
loadDiscoveryResources();
|
loadDiscoveryResources();
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
// Keep the host status dots live: refresh the agent join periodically and on
|
||||||
|
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
|
||||||
|
refreshAgents();
|
||||||
|
setInterval(refreshAgents, 30000);
|
||||||
|
try {
|
||||||
|
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||||
|
dirAgentSocket.on('agent.telemetry', function(msg){
|
||||||
|
const a = msg && agentsByToken[msg.token];
|
||||||
|
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
|
||||||
|
});
|
||||||
|
dirAgentSocket.on('agent.discovery', function(msg){
|
||||||
|
const a = msg && agentsByToken[msg.token];
|
||||||
|
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
|
||||||
|
});
|
||||||
|
} catch (e) { /* socket is optional; periodic refresh still runs */ }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var userlist;
|
|
||||||
var allGroups = [];
|
|
||||||
|
|
||||||
// A member DN under the groups base is a nested group, not a person. Both
|
|
||||||
// live in the same `member` attribute, so they have to be told apart here --
|
|
||||||
// otherwise a nested group renders as a user whose name happens to be the
|
|
||||||
// group's, and its remove button calls the user endpoint and 404s.
|
|
||||||
function isGroupDn(dn){
|
|
||||||
return /,ou=groups,/i.test(String(dn));
|
|
||||||
}
|
|
||||||
|
|
||||||
function processGroup(value){
|
|
||||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
|
||||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
|
||||||
|
|
||||||
// Split before anything else consumes `member`.
|
|
||||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
|
||||||
return {
|
|
||||||
dn: dn,
|
|
||||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
|
||||||
groupCN: value.cn
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
|
||||||
value.nestedCount = value.nested.length;
|
|
||||||
value.hasNested = value.nestedCount > 0;
|
|
||||||
|
|
||||||
// Candidates to nest: every other group not already nested here. Self is
|
|
||||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
|
||||||
// which is the only place that can see the whole graph.
|
|
||||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
|
||||||
value.toNest = allGroups.filter(function(g){
|
|
||||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
|
||||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
|
||||||
|
|
||||||
value.toAdd = userlist.filter(function(user){
|
|
||||||
return !value.member.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.toAddOwner = userlist.filter(function(user){
|
|
||||||
return !value.owner.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.member = value.member.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.owner = value.owner.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.memberCount = value.member.length;
|
|
||||||
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.groupCN = value.cn;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// app_sso_service_account is a marker group: membership hides an account
|
|
||||||
// from the Users page's People tab entirely (see users.ejs), which is
|
|
||||||
// exactly right for a non-person account but has silently made a real
|
|
||||||
// person's account look "gone" before (nothing else about it changes).
|
|
||||||
// Everywhere else in this dropdown just fires the PUT directly; only
|
|
||||||
// this one group gets a confirmation first.
|
|
||||||
function addMemberClick(event, groupCN, uid, el){
|
|
||||||
event.preventDefault();
|
|
||||||
const $el = $(el);
|
|
||||||
(async function(){
|
|
||||||
if (groupCN === 'app_sso_service_account') {
|
|
||||||
const ok = await app.messages.confirm(
|
|
||||||
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
|
||||||
$el.closest('.card'), 'warning'
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
|
||||||
await addedUser(data.message, groupCN, uid, $el);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
|
||||||
let data = await app.group.get(group);
|
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
|
||||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
|
||||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySort() {
|
|
||||||
const sort = $('#groupSort').val();
|
|
||||||
const scope = $.scope.groupCard;
|
|
||||||
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
|
|
||||||
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSearch(g) {
|
|
||||||
const q = $('#groupSearch').val().toLowerCase().trim();
|
|
||||||
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
applySort();
|
|
||||||
const groups = allGroups.filter(matchesSearch);
|
|
||||||
$.scope.groupCard.empty();
|
|
||||||
$.scope.groupCard.push(...groups);
|
|
||||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableAJAX(revealCn) {
|
|
||||||
let data = await app.group.list();
|
|
||||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
|
||||||
// it has to see the full set before the map runs -- assigning only the
|
|
||||||
// mapped result would leave every dropdown empty on first load (and one
|
|
||||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
|
||||||
allGroups = data.results;
|
|
||||||
allGroups = data.results.map(processGroup);
|
|
||||||
applyFilters();
|
|
||||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNestedClick(event, groupCN, childCN, el){
|
|
||||||
event.preventDefault();
|
|
||||||
const $card = $('#group-card-' + groupCN);
|
|
||||||
(async function(){
|
|
||||||
try {
|
|
||||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $card, 'success');
|
|
||||||
} catch(e) {
|
|
||||||
// 409 here is the cycle guard or an already-nested group -- both
|
|
||||||
// carry a specific server message worth showing verbatim.
|
|
||||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeNested(groupCN, childCN, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(
|
|
||||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
|
||||||
$item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMember(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
|
||||||
await app.api.delete(`group/${cn}`);
|
|
||||||
$.scope.groupCard.remove('cn', cn);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
userlist = (await app.user.list()).results;
|
|
||||||
tableAJAX();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="container mt-4">
|
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
|
||||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
|
||||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
|
||||||
</div>
|
|
||||||
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
|
|
||||||
<option value="name-asc">Name A → Z</option>
|
|
||||||
<option value="name-desc">Name Z → A</option>
|
|
||||||
<option value="members-desc">Most members</option>
|
|
||||||
<option value="members-asc">Fewest members</option>
|
|
||||||
</select>
|
|
||||||
<span id="groupCount" class="text-muted text-nowrap small"></span>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-object-group"></i>
|
|
||||||
Add new group
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
|
|
||||||
<div class="card shadow col">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>
|
|
||||||
<i class="fa-solid fa-arrows-down-to-people"></i>
|
|
||||||
Group: {{ cn }}
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</h5>
|
|
||||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
|
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
Members
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
|
||||||
<i class="fa-solid fa-layer-group"></i>
|
|
||||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
|
||||||
<i class="fa-solid fa-user-tie"></i>
|
|
||||||
Owners
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item float-end">
|
|
||||||
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #member }}
|
|
||||||
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /member }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
|
||||||
{{ #toAdd }}{{#.}}
|
|
||||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAdd }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
|
||||||
<p class="text-muted small mb-2">
|
|
||||||
Everyone in a nested group is a member of this one, at any depth.
|
|
||||||
</p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #nested }}
|
|
||||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
|
||||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-link-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /nested }}
|
|
||||||
{{ ^hasNested }}
|
|
||||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
|
||||||
{{ /hasNested }}
|
|
||||||
</ul>
|
|
||||||
<div class="dropdown mt-2">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
|
||||||
{{ #toNest }}
|
|
||||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
|
||||||
{{ /toNest }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #owner }}
|
|
||||||
<li class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /owner }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="dropdown float-start">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
|
|
||||||
{{ #toAddOwner }}{{#.}}
|
|
||||||
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAddOwner }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="float-end">
|
|
||||||
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
|
|
||||||
<i class="fa-solid fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Created: {{createTimestamp}}<br />
|
|
||||||
Last Modified: {{modifyTimestamp}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -657,8 +657,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="own-api-tokens-section" style="display:none">
|
<div id="own-api-tokens-section" style="display:none">
|
||||||
<div class="row mt-3 justify-content-center">
|
<div class="row mt-3">
|
||||||
<div class="col-md-8">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
|
|||||||
@@ -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 { %>
|
||||||
|
|||||||
+283
-22
@@ -1,26 +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">
|
||||||
</ul>
|
<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>
|
||||||
</div>
|
<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>
|
||||||
<div class="tab-content">
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<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="row">
|
<div class="p-3">
|
||||||
|
<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>
|
||||||
@@ -28,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>
|
||||||
@@ -44,17 +47,20 @@
|
|||||||
<h4>Select a secret to view its details</h4>
|
<h4>Select a secret to view its details</h4>
|
||||||
</div>
|
</div>
|
||||||
</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="row">
|
<div class="p-3">
|
||||||
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
||||||
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
||||||
@@ -66,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>
|
||||||
@@ -81,8 +87,110 @@ 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>
|
||||||
|
|
||||||
|
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane fade" id="tab-shared">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">My shared secrets</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush" id="shared-mine-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="card-title mb-0">Shared with me</h5></div>
|
||||||
|
<div class="list-group list-group-flush" id="shared-granted-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Shared Secret Modal -->
|
||||||
|
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">New Shared Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name (slug)</label>
|
||||||
|
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Data (JSON)</label>
|
||||||
|
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
|
||||||
|
"key": "value"
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="shared-create-error"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Grants Modal -->
|
||||||
|
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Share</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
|
||||||
|
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
|
||||||
|
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="grants-error"></div>
|
||||||
|
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Shared Secret Modal -->
|
||||||
|
<div class="modal fade" id="sharedViewModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="shared-view-title">Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +242,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
// key relative to the subject's namespace (so 'foo' for a user means
|
// key relative to the subject's namespace (so 'foo' for a user means
|
||||||
// secret/data/users/<uid>/foo).
|
// secret/data/users/<uid>/foo).
|
||||||
function vpath(kind, key) {
|
function vpath(kind, key) {
|
||||||
return `secret/${kind}/${VAULT_BASE}${key}`;
|
let cleanKey = key || '';
|
||||||
|
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
|
||||||
|
if (VAULT_BASE) {
|
||||||
|
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
|
||||||
|
}
|
||||||
|
return `secret/${kind}/${cleanKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiCall(method, path, body = null) {
|
function apiCall(method, path, body = null) {
|
||||||
@@ -156,7 +269,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
|
|
||||||
async function loadSecrets() {
|
async function loadSecrets() {
|
||||||
try {
|
try {
|
||||||
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
|
||||||
|
const res = await apiCall('GET', listPath);
|
||||||
const listEl = document.getElementById('secrets-list');
|
const listEl = document.getElementById('secrets-list');
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||||
@@ -301,6 +415,152 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared secrets tab ──────────────────────────────────────────────
|
||||||
|
let currentShared = null;
|
||||||
|
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
|
||||||
|
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
|
||||||
|
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
|
||||||
|
|
||||||
|
function sharedApi(path, method = 'GET', body = null) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('/api/shared-secrets' + path, opts).then(async res => {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShared() {
|
||||||
|
try {
|
||||||
|
const res = await sharedApi('/');
|
||||||
|
const items = (res && res.items) || [];
|
||||||
|
renderSharedMine(items.filter(i => i.role === 'owner'));
|
||||||
|
renderSharedGranted(items.filter(i => i.role === 'grantee'));
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('shared-mine-list').innerHTML =
|
||||||
|
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSharedMine(items) {
|
||||||
|
const el = document.getElementById('shared-mine-list');
|
||||||
|
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
|
||||||
|
el.innerHTML = '';
|
||||||
|
items.forEach(s => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSharedGranted(items) {
|
||||||
|
const el = document.getElementById('shared-granted-list');
|
||||||
|
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
|
||||||
|
el.innerHTML = '';
|
||||||
|
items.forEach(s => {
|
||||||
|
const row = document.createElement('a');
|
||||||
|
row.href = '#';
|
||||||
|
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||||
|
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
|
||||||
|
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateSharedModal() {
|
||||||
|
currentShared = null;
|
||||||
|
document.getElementById('shared-slug-input').value = '';
|
||||||
|
document.getElementById('shared-desc-input').value = '';
|
||||||
|
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
|
||||||
|
document.getElementById('shared-create-error').classList.add('d-none');
|
||||||
|
sharedCreateModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSharedSecret() {
|
||||||
|
const err = document.getElementById('shared-create-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
|
||||||
|
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
|
||||||
|
try {
|
||||||
|
await sharedApi('/', 'POST', {
|
||||||
|
slug: document.getElementById('shared-slug-input').value.trim(),
|
||||||
|
description: document.getElementById('shared-desc-input').value.trim(),
|
||||||
|
data
|
||||||
|
});
|
||||||
|
sharedCreateModal.hide();
|
||||||
|
await loadShared();
|
||||||
|
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewShared(s) {
|
||||||
|
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
|
||||||
|
document.getElementById('shared-view-content').textContent = 'Loading...';
|
||||||
|
sharedViewModal.show();
|
||||||
|
try {
|
||||||
|
const res = await apiCall('GET', 'secret/data/' + s.path);
|
||||||
|
document.getElementById('shared-view-content').textContent =
|
||||||
|
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGrants(id) {
|
||||||
|
currentShared = id;
|
||||||
|
document.getElementById('grants-error').classList.add('d-none');
|
||||||
|
document.getElementById('grant-id-input').value = '';
|
||||||
|
sharedGrantsModal.show();
|
||||||
|
try {
|
||||||
|
const res = await sharedApi('/' + id + '/grants');
|
||||||
|
const grants = (res && res.grants) || [];
|
||||||
|
const el = document.getElementById('grants-list');
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
|
||||||
|
grants.forEach(g => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGrant() {
|
||||||
|
const err = document.getElementById('grants-error');
|
||||||
|
err.classList.add('d-none');
|
||||||
|
try {
|
||||||
|
await sharedApi('/' + currentShared + '/grants', 'POST', {
|
||||||
|
granteeType: document.getElementById('grant-type-input').value,
|
||||||
|
granteeId: document.getElementById('grant-id-input').value.trim()
|
||||||
|
});
|
||||||
|
document.getElementById('grant-id-input').value = '';
|
||||||
|
openGrants(currentShared);
|
||||||
|
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeGrant(grantId) {
|
||||||
|
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
|
||||||
|
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteShared(id) {
|
||||||
|
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
|
||||||
|
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
const user = await app.auth.forceLogin();
|
const user = await app.auth.forceLogin();
|
||||||
if (!user) return; // not logged in — forceLogin redirected to /login
|
if (!user) return; // not logged in — forceLogin redirected to /login
|
||||||
@@ -314,6 +574,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||||
}
|
}
|
||||||
loadSecrets();
|
loadSecrets();
|
||||||
|
loadShared();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user