Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 |
@@ -1,3 +1,23 @@
|
||||
# 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
|
||||
- Added WebSocket endpoint for theta-agent C2
|
||||
|
||||
|
||||
+71
-67
@@ -1,88 +1,92 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery Agents
|
||||
title: Theta Agent & Endpoint Management
|
||||
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
|
||||
// nodejs/agents/discovery/my_custom_agent.js
|
||||
module.exports = {
|
||||
discover: async (config) => {
|
||||
const { url, apiKey } = config; // Provided by your configuration
|
||||
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
### 2. Real-Time Telemetry Streaming
|
||||
Every 30 seconds, the agent streams real-time performance metrics:
|
||||
- **CPU Load**: System-wide CPU utilization percentage.
|
||||
- **Memory Utilization**: RAM usage percentage and available memory.
|
||||
- **Disk Utilization**: Root filesystem usage percentage.
|
||||
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
|
||||
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
|
||||
|
||||
// 1. Fetch your data from an API
|
||||
// const data = await fetch(...);
|
||||
---
|
||||
|
||||
// 2. Map data to Resources
|
||||
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' }
|
||||
]
|
||||
}
|
||||
});
|
||||
## Local-First Security & Capability Matrix
|
||||
|
||||
// 3. Map relations to Edges (optional)
|
||||
edges.push({
|
||||
parentSlug: 'my-switch-01',
|
||||
childSlug: 'some-connected-client-slug',
|
||||
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
||||
});
|
||||
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.
|
||||
|
||||
return { resources, edges };
|
||||
}
|
||||
};
|
||||
| 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`:
|
||||
|
||||
```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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```bash
|
||||
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -60,6 +60,7 @@ backend, that's the niche.
|
||||
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.
|
||||
- **[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
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
||||
const vaultBroker = require('./utils/vault_broker');
|
||||
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||
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
|
||||
// used, this is what will be called.
|
||||
|
||||
@@ -60,6 +60,13 @@ models.initORM().then(() => {
|
||||
initScheduler(conf.discovery).catch(err => {
|
||||
console.error('Failed to initialize scheduler:', err);
|
||||
});
|
||||
|
||||
// Keep external-app vault tokens alive: renew every stored accessor now and
|
||||
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
|
||||
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
|
||||
if (process.env.VAULT_TOKEN) {
|
||||
require('../utils/vault_broker').startAppTokenRenewal();
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to initialize ORM:', err);
|
||||
process.exit(1);
|
||||
|
||||
Binary file not shown.
@@ -17,6 +17,9 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||
const { AccessRequest } = require('./access_request');
|
||||
const { Webhook } = require('./webhook');
|
||||
const { PluginInstance } = require('./plugin_instance');
|
||||
const { SharedSecret } = require('./shared_secret');
|
||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||
const { VaultAppToken } = require('./vault_app_token');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -31,15 +34,48 @@ async function initORM() {
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] ORM initialized successfully');
|
||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||
await healSchema();
|
||||
} catch (err) {
|
||||
console.error('[initORM] ORM initialization failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||
// which creates missing tables but never touches existing ones — so a column
|
||||
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||
// in an upgraded deployment's database and every query on the model fails
|
||||
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||
// any dialect via the query interface, and fail-soft per column so one bad
|
||||
// attribute can't take the boot down.
|
||||
async function healSchema() {
|
||||
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||
if (!adapter || !adapter.sequelize) return;
|
||||
const sequelize = adapter.sequelize;
|
||||
const qi = sequelize.getQueryInterface();
|
||||
for (const SM of Object.values(sequelize.models)) {
|
||||
const table = SM.getTableName();
|
||||
let existing;
|
||||
try { existing = await qi.describeTable(table); }
|
||||
catch (e) { continue; } // no table yet — sync() handles creation
|
||||
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||
const col = attr.field || name;
|
||||
if (existing[col]) continue;
|
||||
try {
|
||||
await qi.addColumn(table, col, attr);
|
||||
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||
} catch (e) {
|
||||
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.initORM = initORM;
|
||||
|
||||
@@ -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
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.0",
|
||||
"version": "1.23.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.20.0",
|
||||
"version": "1.23.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
|
||||
module.exports = function initAgentWebSockets(app) {
|
||||
if (!app.wss) {
|
||||
console.warn("WebSocket server for agents is not initialized.");
|
||||
@@ -71,8 +75,23 @@ module.exports = function initAgentWebSockets(app) {
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// REST API routes for Agent Management (mounted under /api/agent)
|
||||
// REST API routes for Agent Management (mounted under /api/agent). The agent
|
||||
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
|
||||
// bin/www with its own ?token= auth — unaffected by the express middleware
|
||||
// here. These REST routes are admin-facing, so they're auth + admin gated.
|
||||
const router = express.Router();
|
||||
router.use(middleware.auth);
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/nodes', (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -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 || {};
|
||||
meta.managed = true;
|
||||
await resource.update({ metadata: meta });
|
||||
await Resource.update(resource.id, { metadata: meta });
|
||||
|
||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -59,6 +59,12 @@ router.get('/overview', function(req, res) {
|
||||
res.render('overview', {...values});
|
||||
});
|
||||
|
||||
// Connected theta-agent hosts + live telemetry (admin). Data from
|
||||
// GET /api/agent/nodes; live updates via socket.io 'agent.*' events.
|
||||
router.get('/agents', function(req, res) {
|
||||
res.render('agents', {...values});
|
||||
});
|
||||
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||
@@ -84,14 +90,7 @@ router.get('/discovery', function(req, res, next) {
|
||||
});
|
||||
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
||||
// 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 });
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/vault', function(req, res) {
|
||||
|
||||
@@ -12,32 +12,39 @@ class DiscoveryReconciler {
|
||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||
|
||||
let existing = null;
|
||||
|
||||
// Attempt matching by MAC if available (case-insensitive)
|
||||
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
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) {
|
||||
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) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r =>
|
||||
r.metadata && r.metadata.interfaces &&
|
||||
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
|
||||
r.metadata && (
|
||||
(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 = [];
|
||||
if (res.metadata.interfaces) {
|
||||
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||
}
|
||||
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||
if (res.metadata.address) {
|
||||
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||
}
|
||||
|
||||
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||
|
||||
if (!existing && ipsToMatch.length > 0) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r => {
|
||||
if (!r.metadata) return false;
|
||||
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||
if (r.metadata.address) {
|
||||
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||
@@ -46,14 +53,17 @@ class DiscoveryReconciler {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback matching by Slug or Name
|
||||
|
||||
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||
if (!existing && (res.slug || res.name)) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r =>
|
||||
(res.slug && r.slug === res.slug) ||
|
||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
||||
);
|
||||
const inputName = normalizeHost(res.name || res.slug);
|
||||
existing = allRes.find(r => {
|
||||
if (res.slug && r.slug === res.slug) return true;
|
||||
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) {
|
||||
|
||||
@@ -15,8 +15,36 @@ jest.mock('redis', () => ({
|
||||
})
|
||||
}));
|
||||
|
||||
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
|
||||
// can run without a database.
|
||||
jest.mock('../models/shared_secret', () => ({
|
||||
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
|
||||
}));
|
||||
jest.mock('../models/shared_secret_grant', () => ({
|
||||
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
|
||||
}));
|
||||
jest.mock('../models/vault_app_token', () => {
|
||||
const rows = [];
|
||||
const VaultAppToken = {
|
||||
_rows: rows,
|
||||
list: jest.fn(async () => rows),
|
||||
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
|
||||
create: jest.fn(async (data) => {
|
||||
const row = {
|
||||
...data,
|
||||
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
|
||||
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
|
||||
};
|
||||
rows.push(row);
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
return { VaultAppToken };
|
||||
});
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const vaultBroker = require('../utils/vault_broker');
|
||||
const { VaultAppToken } = require('../models/vault_app_token');
|
||||
|
||||
describe('vault_broker admin policy', () => {
|
||||
beforeEach(() => {
|
||||
@@ -29,8 +57,8 @@ describe('vault_broker admin policy', () => {
|
||||
return { status: 404, text: async () => '' };
|
||||
}
|
||||
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 = ["list", "read", "delete"] }');
|
||||
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||
return { status: 204, ok: true };
|
||||
}
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -44,9 +44,9 @@ module.exports = {
|
||||
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
||||
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
{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>/*.
|
||||
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
|
||||
{href: '/agents', icon: 'fa-solid fa-microchip', label: 'Agents', groups: ['app_sso_admin', 'admin']},
|
||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||
],
|
||||
};
|
||||
|
||||
+241
-63
@@ -4,15 +4,25 @@
|
||||
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||
// `sso-broker` token role created by theta-env/setup.sh.
|
||||
//
|
||||
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||
// secret/* admin UI sessions (sso-admin policy)
|
||||
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||
// secret/shared/<uid>/* user-owned shared KV (user-<uid> 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
|
||||
// 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
|
||||
// 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).
|
||||
//
|
||||
// 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 { createClient } = require('redis');
|
||||
@@ -20,6 +30,9 @@ const express = require('express');
|
||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const permission = require('./permission');
|
||||
const { SharedSecret } = require('../models/shared_secret');
|
||||
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||
const { VaultAppToken } = require('../models/vault_app_token');
|
||||
|
||||
const ROLE = 'sso-broker';
|
||||
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||
@@ -54,52 +67,86 @@ async function bao(method, path, body) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
||||
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
||||
// a list grant on a directory path) propagate on the next vault-page visit
|
||||
// without an operator re-running setup.sh. Skipping on an existing policy
|
||||
// would strand the old, narrower HCL forever.
|
||||
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
|
||||
// content and only PUT when it differs. `bao policy write` is an idempotent
|
||||
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
||||
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||
async function ensurePolicy(name, hcl) {
|
||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||
if (existing.status !== 200 && existing.status !== 404) {
|
||||
const t = await existing.text().catch(() => '');
|
||||
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 });
|
||||
}
|
||||
|
||||
// Mint a token through the sso-broker role with the given policies. Returns
|
||||
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
||||
async function mintToken(policies) {
|
||||
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
||||
// Mint a token through a token role with the given policies. Returns
|
||||
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
|
||||
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
|
||||
// from cache) and sso-app (768h period — long-lived external-app credentials,
|
||||
// kept alive via their stored accessor by the renewal loop below).
|
||||
async function mintToken(policies, role = ROLE) {
|
||||
const res = await bao('POST', `auth/token/create/${role}`, { policies });
|
||||
const json = await res.json();
|
||||
const token = json && json.auth && json.auth.client_token;
|
||||
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||
return { token, ttl };
|
||||
return { token, accessor: json.auth.accessor, ttl };
|
||||
}
|
||||
|
||||
// ── Shared-secret policy rules ───────────────────────────────────────────────
|
||||
// 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 ──────────────────────────────────────────────────────────
|
||||
function userPolicyHcl(uid) {
|
||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||
// into a policy path, so reject anything but a safe charset.
|
||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
||||
// directory itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
async function userPolicyHcl(uid) {
|
||||
const granted = await sharedPolicyRules('user', uid);
|
||||
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
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 = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/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"] }
|
||||
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>/*.
|
||||
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
||||
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
|
||||
// (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) {
|
||||
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 cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
||||
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||
return token;
|
||||
@@ -107,47 +154,164 @@ async function getOrCreateUserToken(uid) {
|
||||
|
||||
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||
function adminPolicyHcl() {
|
||||
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
|
||||
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
|
||||
// paths but NOT the root itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
||||
}
|
||||
|
||||
async function getOrCreateAdminToken(uid) {
|
||||
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||
const { token, ttl } = await mintToken(['sso-admin']);
|
||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||
function appPolicyHcl(name) {
|
||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
||||
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
||||
return `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 = ["list", "read", "delete"] }`;
|
||||
async function appPolicyHcl(name) {
|
||||
const granted = await sharedPolicyRules('app', name);
|
||||
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 = ["create", "read", "update", "delete", "list"] }
|
||||
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
|
||||
// (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
|
||||
// tokens. The caller must record it in the external app immediately.
|
||||
async function mintAppToken(name) {
|
||||
// tokens. The caller must record it in the external app immediately. Later
|
||||
// grants to the app edit app-<name> policy content (live-applied to this token).
|
||||
//
|
||||
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
|
||||
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
|
||||
// token alive and lets a re-mint revoke the app's previous token so exactly
|
||||
// one credential per app is ever live.
|
||||
async function mintAppToken(name, actorUid) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||
}
|
||||
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
|
||||
const { token, ttl } = await mintToken([`app-${name}`]);
|
||||
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
|
||||
// App tokens are long-lived credentials: mint via the sso-app role (768h
|
||||
// period) so a renewal inside every 32-day window keeps them alive forever.
|
||||
// Fall back to the broker's own 24h role on deployments whose setup.sh
|
||||
// predates the sso-app role (re-running setup.sh creates it).
|
||||
let minted;
|
||||
try {
|
||||
minted = await mintToken([`app-${name}`], 'sso-app');
|
||||
} catch (e) {
|
||||
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
|
||||
minted = await mintToken([`app-${name}`]);
|
||||
}
|
||||
const { token, accessor, ttl } = minted;
|
||||
// Replace the app's accessor row; revoke the superseded token (best-effort —
|
||||
// it may already be expired) so re-minting never leaves a zombie credential.
|
||||
try {
|
||||
const existing = await VaultAppToken.getByName(name);
|
||||
if (existing) {
|
||||
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
|
||||
await existing.delete();
|
||||
}
|
||||
if (accessor) {
|
||||
await VaultAppToken.create({
|
||||
name, accessor,
|
||||
lastRenewedAt: Date.now(),
|
||||
created_by: actorUid, created_on: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Accessor bookkeeping must never block handing the token out; without a
|
||||
// row the token simply isn't auto-renewed (it still lives one full period).
|
||||
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
|
||||
}
|
||||
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||
}
|
||||
|
||||
// ── App-token renewal loop ──────────────────────────────────────────────────
|
||||
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
|
||||
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
|
||||
// far inside both possible periods (24h fallback and 768h), so a downstream
|
||||
// app's token stays valid for as long as sso is running. Failures are recorded
|
||||
// on the row (visible to admins in the DB / future UI) and never throw.
|
||||
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
|
||||
let renewTimer;
|
||||
|
||||
async function renewAppTokens() {
|
||||
let rows;
|
||||
try { rows = await VaultAppToken.list(); }
|
||||
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
|
||||
if (res.ok) {
|
||||
await row.update({ lastRenewedAt: Date.now(), lastError: null });
|
||||
} else {
|
||||
const text = await res.text().catch(() => '');
|
||||
// 400 "invalid accessor" = token expired or was revoked out-of-band;
|
||||
// keep the row + error so the admin can see the app needs a re-mint.
|
||||
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
|
||||
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
|
||||
}
|
||||
} catch (e) {
|
||||
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
|
||||
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the loop (idempotent). unref() so an open handle never blocks exit.
|
||||
function startAppTokenRenewal() {
|
||||
if (renewTimer) return renewTimer;
|
||||
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
|
||||
renewTimer = setInterval(() => {
|
||||
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
|
||||
}, RENEW_INTERVAL_MS);
|
||||
if (renewTimer.unref) renewTimer.unref();
|
||||
return renewTimer;
|
||||
}
|
||||
|
||||
// ── Grant / revoke shared-secret access ─────────────────────────────────────
|
||||
// Creating a grant writes the DB row and then edits the grantee's policy content
|
||||
// to add read on the shared path; revoking removes both. Because OpenBao parses
|
||||
// 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 ───────────────────
|
||||
// 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
|
||||
@@ -156,11 +320,12 @@ async function mintAppToken(name) {
|
||||
// client's sso auth headers so OpenBao never sees them.
|
||||
|
||||
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';
|
||||
|
||||
async function isAdmin(user) {
|
||||
try {
|
||||
await permission.byGroup(user, [ADMIN_GROUP]);
|
||||
await permission.byGroup(user, ADMIN_GROUPS);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -189,17 +354,13 @@ async function scopeGuard(req, res, next) {
|
||||
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);
|
||||
if (norm === null) {
|
||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||
}
|
||||
const base = `/secret/users/${uid}`;
|
||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
||||
const userBase = `/secret/users/${uid}`;
|
||||
const sharedBase = `/secret/shared`;
|
||||
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||
}
|
||||
@@ -214,15 +375,20 @@ function vaultProxy() {
|
||||
target: VAULT_ADDR,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/vault': '/v1' },
|
||||
on: {
|
||||
proxyReq(proxyReq, req, res, options) {
|
||||
fixRequestBody(proxyReq, req, res, options);
|
||||
// Inject ONLY the server-minted scoped token; strip the client's
|
||||
// sso session/api auth so it never reaches OpenBao.
|
||||
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||
proxyReq.removeHeader('auth-token');
|
||||
proxyReq.removeHeader('authorization');
|
||||
},
|
||||
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
|
||||
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
|
||||
// which shipped this proxy with NO token injection — every /api/vault
|
||||
// call reached OpenBao unauthenticated and 403'd.
|
||||
onProxyReq(proxyReq, req, res, options) {
|
||||
// Header ops MUST precede fixRequestBody: it write()s the parsed body
|
||||
// onto proxyReq, which flushes headers — setHeader after that throws
|
||||
// (swallowed upstream), silently dropping the token on every write.
|
||||
// Inject ONLY the server-minted scoped token; strip the client's
|
||||
// sso session/api auth so it never reaches OpenBao.
|
||||
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||
proxyReq.removeHeader('auth-token');
|
||||
proxyReq.removeHeader('authorization');
|
||||
fixRequestBody(proxyReq, req, res, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -236,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
const name = (req.body && req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||
const result = await mintAppToken(name);
|
||||
const result = await mintAppToken(name, req.user && req.user.uid);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||
@@ -252,4 +418,16 @@ module.exports = {
|
||||
scopeGuard,
|
||||
vaultProxy,
|
||||
mintAppRouter,
|
||||
};
|
||||
// app-token lifecycle
|
||||
renewAppTokens,
|
||||
startAppTokenRenewal,
|
||||
VaultAppToken,
|
||||
// sharing
|
||||
SharedSecret,
|
||||
SharedSecretGrant,
|
||||
userPolicyHcl,
|
||||
appPolicyHcl,
|
||||
grantSharedSecret,
|
||||
revokeSharedSecret,
|
||||
reconcileGrantee,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="fa-solid fa-microchip"></i> Theta Agents <small class="text-muted">(connected hosts)</small></h2>
|
||||
<button class="btn btn-outline-primary" onclick="loadAgents()"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Connected agents</h5></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
<th style="width:110px">CPU</th>
|
||||
<th style="width:110px">RAM</th>
|
||||
<th style="width:110px">Disk</th>
|
||||
<th>ZFS</th>
|
||||
<th>GPU</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agents-tbody">
|
||||
<tr><td colspan="9" class="text-center text-muted">Loading agents...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-3">
|
||||
Live data from the theta-agent telemetry stream. An agent reports hostname/IP discovery and
|
||||
CPU/RAM/disk/ZFS/GPU usage every ~60s over the WebSocket; "Online" means seen in the last 90s.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
let agentsById = {}; // token -> agent record
|
||||
|
||||
function bar(val) {
|
||||
val = Math.max(0, Math.min(100, val || 0));
|
||||
return `<div class="progress" style="height:8px"><div class="progress-bar" role="progressbar" style="width:${val}%"></div></div>`;
|
||||
}
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const m = moment(iso);
|
||||
return m.isValid() ? m.fromNow() : '';
|
||||
}
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return app.util.escapeHtml(String(s));
|
||||
}
|
||||
|
||||
function renderRow(id, a) {
|
||||
const d = a.discovery || {};
|
||||
const t = a.telemetry || {};
|
||||
const online = !!a.isOnline;
|
||||
const badge = `<span class="badge ${online ? 'bg-success' : 'bg-secondary'}">${online ? 'Online' : 'Offline'}</span>`;
|
||||
const cpu = t.cpu_usage_percent != null ? t.cpu_usage_percent : 0;
|
||||
const ram = t.ram_usage_percent != null ? t.ram_usage_percent : 0;
|
||||
const disk = t.disk_usage_percent != null ? t.disk_usage_percent : 0;
|
||||
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
|
||||
return `<tr id="agent-${id}">
|
||||
<td><strong>${esc(a.hostname || 'unknown')}</strong>${d.location ? `<div class="small text-muted">${esc(d.location)}</div>` : ''}</td>
|
||||
<td>${esc(a.ipAddress || '')}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${cpu}% ${bar(cpu)}</td>
|
||||
<td>${ram}% ${bar(ram)}</td>
|
||||
<td>${disk}% ${bar(disk)}</td>
|
||||
<td>${esc(t.zfs_health || 'N/A')}</td>
|
||||
<td>${gpu}</td>
|
||||
<td class="small text-muted">${timeAgo(a.lastSeen)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const tbody = document.getElementById('agents-tbody');
|
||||
try {
|
||||
const res = await app.api.get('agent/nodes');
|
||||
const agents = (res && res.agents) || [];
|
||||
agentsById = {};
|
||||
agents.forEach(a => { agentsById[a.token] = a; });
|
||||
tbody.innerHTML = agents.length
|
||||
? agents.map(a => renderRow(a.token, a)).join('')
|
||||
: '<tr><td colspan="9" class="text-center text-muted">No agents connected.</td></tr>';
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error loading agents: ${esc(err && err.message || err)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Live updates from the server's agent.* socket.io broadcasts. The app's
|
||||
// default socket is scoped to P2PSub, so open a dedicated socket here.
|
||||
const agentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||
agentSocket.on('agent.telemetry', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.telemetry = msg.payload; a.isOnline = true; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
agentSocket.on('agent.discovery', (msg) => {
|
||||
const a = agentsById[msg && msg.token];
|
||||
if (a) { a.discovery = msg.payload; a.hostname = (msg.payload && msg.payload.hostname) || a.hostname; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
|
||||
});
|
||||
|
||||
loadAgents();
|
||||
// Re-fetch periodically to reflect connect/disconnect + isOnline (90s window).
|
||||
setInterval(loadAgents, 30000);
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+286
-256
@@ -1,11 +1,16 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||
|
||||
var messagingTypes = {};
|
||||
var messagingPlugins = [];
|
||||
|
||||
$(document).ready(function() {
|
||||
loadConf();
|
||||
loadProxyConf();
|
||||
loadTos();
|
||||
loadMessagingPlugins();
|
||||
});
|
||||
|
||||
async function loadConf() {
|
||||
@@ -44,7 +49,7 @@
|
||||
|
||||
async function saveConf() {
|
||||
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 = {
|
||||
smtp: {
|
||||
@@ -72,11 +77,11 @@
|
||||
|
||||
try {
|
||||
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) {
|
||||
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||
} 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;
|
||||
}
|
||||
|
||||
const $inputGroup = $('#test-email-to').closest('.input-group');
|
||||
const btn = $inputGroup.find('button');
|
||||
const btn = $('#btn-test-email');
|
||||
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 {
|
||||
// First save the SMTP config, then send test email
|
||||
const payload = {
|
||||
smtp: {
|
||||
host: $('#smtp-host').val(),
|
||||
@@ -104,11 +107,7 @@
|
||||
secure: $('#smtp-secure').is(':checked')
|
||||
}
|
||||
};
|
||||
|
||||
// Save config first
|
||||
await app.api.post('conf', payload);
|
||||
|
||||
// Then send test email
|
||||
const result = await app.api.post('conf/test-email', { to });
|
||||
app.messages.toast(result.message || 'Test email sent!', 'success');
|
||||
$('#test-email-to').val('');
|
||||
@@ -126,13 +125,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const $inputGroup = $('#test-sms-to').closest('.input-group');
|
||||
const btn = $inputGroup.find('button');
|
||||
const btn = $('#btn-test-sms');
|
||||
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 {
|
||||
// First save the VoIP.ms config, then send test SMS
|
||||
const payload = {
|
||||
voipms: {
|
||||
username: $('#voipms-username').val(),
|
||||
@@ -140,11 +137,7 @@
|
||||
password: $('#voipms-password').val()
|
||||
}
|
||||
};
|
||||
|
||||
// Save config first
|
||||
await app.api.post('conf', payload);
|
||||
|
||||
// Then send test SMS
|
||||
const result = await app.api.post('conf/test-sms', { to });
|
||||
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
||||
$('#test-sms-to').val('');
|
||||
@@ -182,7 +175,7 @@
|
||||
|
||||
async function saveProxyConf() {
|
||||
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 = {
|
||||
oidc: {
|
||||
@@ -201,22 +194,18 @@
|
||||
} catch (error) {
|
||||
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||
} 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() {
|
||||
try {
|
||||
const tos = await app.tos.get();
|
||||
document.getElementById('tos-content').value = tos.content;
|
||||
document.getElementById('tos-meta').textContent =
|
||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||
if (tos && tos.content) {
|
||||
document.getElementById('tos-content').value = tos.content;
|
||||
document.getElementById('tos-meta').textContent =
|
||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load ToS:', e);
|
||||
}
|
||||
@@ -248,246 +237,287 @@
|
||||
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>
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||
settings. These are stored securely in OpenBao and take effect immediately.
|
||||
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
||||
are masked — leave them unchanged to keep the stored value.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
||||
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<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 class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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 class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
|
||||
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
|
||||
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
|
||||
</button>
|
||||
</li>
|
||||
<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">
|
||||
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
|
||||
</button>
|
||||
</li>
|
||||
<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">
|
||||
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
|
||||
<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>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" id="smtp-host">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" id="smtp-port">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">User</label>
|
||||
<input type="text" class="form-control" id="smtp-user">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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 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">
|
||||
<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>
|
||||
<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 class="card-body p-4">
|
||||
<div class="tab-content" id="confTabContent">
|
||||
|
||||
<!-- OAuth & JWT Tab -->
|
||||
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
|
||||
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Tab -->
|
||||
<div class="tab-pane fade" id="oauth" 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-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="oauth-issuer">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||
<!-- SMTP Tab -->
|
||||
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
|
||||
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label fw-semibold">SMTP Host</label>
|
||||
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label fw-semibold">Port</label>
|
||||
<input type="number" class="form-control" id="smtp-port" placeholder="587">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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 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 -->
|
||||
<div class="tab-pane fade" id="sms" 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-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Username</label>
|
||||
<input type="text" class="form-control" id="voipms-username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">DID (sender number)</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||
<!-- SMS & Messaging Tab -->
|
||||
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
|
||||
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">API Username</label>
|
||||
<input type="text" class="form-control" id="voipms-username">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">DID Sender Number</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">API Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
</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 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">
|
||||
<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>
|
||||
|
||||
<!-- Proxy Secrets Tab -->
|
||||
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
|
||||
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
|
||||
|
||||
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
|
||||
<div class="mb-3">
|
||||
<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 class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Secrets Tab -->
|
||||
<div class="tab-pane fade" id="proxy" 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-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
|
||||
|
||||
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<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>
|
||||
<!-- Terms of Service Tab -->
|
||||
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
|
||||
<span class="small text-muted" id="tos-meta"></span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
|
||||
<div id="tos-result" style="display:none" class="mt-3"></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>
|
||||
|
||||
<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>
|
||||
|
||||
+164
-8
@@ -13,7 +13,12 @@
|
||||
</li>
|
||||
<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">
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -187,6 +192,23 @@
|
||||
</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>
|
||||
@@ -1188,6 +1210,7 @@
|
||||
allEdges.push(res.results);
|
||||
refreshEdgesUI(resourceId);
|
||||
$('#new-edge-target').val('');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||
@@ -1199,6 +1222,7 @@
|
||||
await app.api.delete('directory-admin/edges/' + id);
|
||||
allEdges = allEdges.filter(e => e.id !== id);
|
||||
refreshEdgesUI($('#res-id').val());
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||
@@ -1238,9 +1262,10 @@
|
||||
function renderDiscoveryTable() {
|
||||
const search = $('#discovery-search-filter').val().toLowerCase();
|
||||
const filtered = allDiscoveryResources.filter(r => {
|
||||
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||
if(isManaged) return false;
|
||||
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
|
||||
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
|
||||
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1493,7 +1518,7 @@
|
||||
`;
|
||||
|
||||
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,
|
||||
size: 'lg'
|
||||
});
|
||||
@@ -1501,12 +1526,143 @@
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
|
||||
// Scheduler tab here was its old home). Discovery inventory + the discovery
|
||||
// results table remain on this page.
|
||||
var discoveryPlugins = [];
|
||||
|
||||
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 = [];
|
||||
|
||||
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">${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>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Slug</label>
|
||||
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Cron Schedule</label>
|
||||
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
|
||||
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
|
||||
</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>
|
||||
<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: 'md'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNewDiscoveryPlugin() {
|
||||
const type = $('#new-plugin-type').val();
|
||||
const name = $('#new-plugin-name').val().trim();
|
||||
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
|
||||
const enabled = $('#new-plugin-enabled').is(':checked');
|
||||
|
||||
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||
|
||||
try {
|
||||
await app.api.post('plugins', {
|
||||
pluginType: type,
|
||||
name,
|
||||
slug,
|
||||
cron,
|
||||
enabled,
|
||||
config: {}
|
||||
});
|
||||
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(){
|
||||
loadDiscoveryResources();
|
||||
loadDiscoveryPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+252
-2
@@ -6,6 +6,7 @@
|
||||
<ul class="nav nav-pills" id="vault-tabs">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +56,7 @@
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
||||
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
||||
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
||||
@@ -83,6 +85,101 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-shared">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<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 bg-light"><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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -134,7 +231,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
|
||||
// secret/data/users/<uid>/foo).
|
||||
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) {
|
||||
@@ -156,7 +258,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
|
||||
async function loadSecrets() {
|
||||
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');
|
||||
listEl.innerHTML = '';
|
||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||
@@ -301,6 +404,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'));
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const user = await app.auth.forceLogin();
|
||||
if (!user) return; // not logged in — forceLogin redirected to /login
|
||||
@@ -314,6 +563,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';
|
||||
}
|
||||
loadSecrets();
|
||||
loadShared();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user