Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b42b3d8a |
@@ -1,3 +1,15 @@
|
|||||||
|
# v1.27.0
|
||||||
|
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
|
||||||
|
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
|
||||||
|
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
|
||||||
|
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
|
||||||
|
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
|
||||||
|
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
|
||||||
|
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
|
||||||
|
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
|
||||||
|
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
|
||||||
|
|
||||||
# v1.26.1
|
# v1.26.1
|
||||||
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar.
|
|||||||
|
|
||||||
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||||
|
|
||||||
|
## Apps tab (admin)
|
||||||
|
|
||||||
|
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||||
|
|
||||||
|
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||||
|
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||||
|
|
||||||
|
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
|
|
||||||
|
## Shared tab
|
||||||
|
|
||||||
|
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||||
|
|
||||||
|
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
|
||||||
|
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
|
||||||
|
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
|
||||||
|
|
||||||
## API Access
|
## API Access
|
||||||
|
|
||||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.27.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.27.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ const agentManager = require('../utils/agent_manager');
|
|||||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
module.exports = function initAgentWebSockets(app) {
|
module.exports = function initAgentWebSockets(app) {
|
||||||
if (!app.wss) {
|
// Only the WebSocket handler needs the WS server. The REST routes mounted
|
||||||
console.warn("WebSocket server for agents is not initialized.");
|
// below (/api/agent/*) must work regardless of the WS server state -- gating
|
||||||
return;
|
// them on `app.wss` made them 404 whenever it wasn't initialized.
|
||||||
}
|
if (app.wss) {
|
||||||
|
|
||||||
app.wss.on('connection', (ws, req) => {
|
app.wss.on('connection', (ws, req) => {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||||
@@ -74,6 +73,7 @@ module.exports = function initAgentWebSockets(app) {
|
|||||||
}));
|
}));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
|
} // end if (app.wss)
|
||||||
|
|
||||||
// REST API routes for Agent Management (mounted under /api/agent). The 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
|
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ async function ensureGroup(name, ownerDn, description) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link a group to a resource only if that link doesn't already exist. The
|
||||||
|
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
|
||||||
|
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||||
|
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||||
|
// "groups appear 3x under a resource" bug. Always check first.
|
||||||
|
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||||
|
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||||
|
}
|
||||||
|
|
||||||
// Provision the site-level groups + the aggregates the per-resource groups nest
|
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||||
// into. Idempotent -- called on every directory list so a site seeded by an
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
// older release gets its groups without a rebuild:
|
// older release gets its groups without a rebuild:
|
||||||
@@ -79,19 +90,20 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
|||||||
// groups as member.
|
// groups as member.
|
||||||
const link = async (cn, isAdmin) => {
|
const link = async (cn, isAdmin) => {
|
||||||
if (!siteResourceId) return;
|
if (!siteResourceId) return;
|
||||||
await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {});
|
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
|
||||||
};
|
};
|
||||||
|
|
||||||
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
await link(sAdmin, true);
|
await link(sAdmin, true);
|
||||||
|
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
|
||||||
|
// them), but are NOT linked to the site resource: a site carries only the god
|
||||||
|
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
|
||||||
|
// aggregates have no modal home; site-wide access is granted via S_super_admin
|
||||||
|
// and per-resource access via the host/app groups.
|
||||||
for (const kind of ['host', 'app']) {
|
for (const kind of ['host', 'app']) {
|
||||||
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
|
||||||
await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
|
||||||
await link(aggAdmin, true);
|
|
||||||
await link(aggAccess, false);
|
|
||||||
}
|
}
|
||||||
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
await link(groups.siteEveryoneCns(siteSlug), false);
|
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||||
@@ -114,29 +126,30 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
|||||||
|
|
||||||
// Provision the per-resource groups for a host/app and nest them into the site
|
// Provision the per-resource groups for a host/app and nest them into the site
|
||||||
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
// The specific group name uses the resource's slug verbatim
|
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
|
||||||
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
|
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
|
||||||
// `kind` (host/app) selects which aggregate the group nests into:
|
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
|
||||||
//
|
//
|
||||||
// {site}_{slug}_admin -> {site}_{slug}_access
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
|
||||||
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
// god_admin -> {site}_{slug}_admin (global super admin)
|
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
|
||||||
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin');
|
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
|
||||||
|
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
|
||||||
|
|
||||||
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||||
|
|
||||||
// Link both groups to the resource so the Directory can show/revoke them.
|
// Link both groups to the resource so the Directory can show/revoke them.
|
||||||
await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {});
|
await ensureResourceGroup(resource.id, accessCn, 'member');
|
||||||
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
|
await ensureResourceGroup(resource.id, adminCn, 'owner');
|
||||||
|
|
||||||
await nestGroup(adminCn, accessCn); // administering implies using
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||||
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||||
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin
|
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
|
||||||
}
|
}
|
||||||
|
|
||||||
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||||
@@ -147,25 +160,24 @@ async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
|||||||
// capability groups following the same shapes.
|
// capability groups following the same shapes.
|
||||||
function validGroupCnsForResource(resource, siteSlug) {
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
const valid = new Set();
|
const valid = new Set();
|
||||||
|
// A site resource only carries god_admin (added by the route) + the site-wide
|
||||||
|
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
|
||||||
|
// specific groups belong to host/app resources, not to the site.
|
||||||
if (resource.kind === 'site') {
|
if (resource.kind === 'site') {
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
valid.add(groups.siteEveryoneCns(siteSlug));
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
for (const k of ['host', 'app']) {
|
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin'));
|
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, k, 'access'));
|
|
||||||
}
|
|
||||||
return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) };
|
|
||||||
}
|
}
|
||||||
const kind = groupKind(resource); // 'host'|'app'|null
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
if (kind) {
|
if (kind) {
|
||||||
const slug = resource.slug; // verbatim (kind is carried in the slug)
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
|
||||||
valid.add(groups.resourceGroupCns(siteSlug, slug, 'access'));
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
valid.add(groups.siteEveryoneCns(siteSlug));
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) };
|
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
|
||||||
}
|
}
|
||||||
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
@@ -439,7 +451,7 @@ router.post('/groups', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const g = await ResourceGroup.create(req.body);
|
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
|
||||||
res.json({ results: g });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
|||||||
const vaultBroker = require('../utils/vault_broker');
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
|
||||||
|
// only reject values that can't be a sane secret path segment (spaces, slashes,
|
||||||
|
// leading non-alnum, too long).
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => {
|
|||||||
if (byId.has(g.id)) continue; // already owner
|
if (byId.has(g.id)) continue; // already owner
|
||||||
byId.set(g.id, { role: 'grantee', ...g });
|
byId.set(g.id, { role: 'grantee', ...g });
|
||||||
}
|
}
|
||||||
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
|
// The `{ role, ...s }` spread above copies only own properties, so the
|
||||||
|
// instance method `path()` is dropped -- call the static builder instead.
|
||||||
|
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const uid = req.user.uid;
|
const uid = req.user.uid;
|
||||||
const slug = String(req.body.slug || '').trim().toLowerCase();
|
const slug = String(req.body.slug || '').trim().toLowerCase();
|
||||||
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
|
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
|
||||||
const description = String(req.body.description || '').trim();
|
const description = String(req.body.description || '').trim();
|
||||||
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
meta.managed = true;
|
||||||
await Resource.update(resource.id, { metadata: meta });
|
// `Resource.update` is not a static — `update` is an instance method
|
||||||
|
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
|
||||||
|
const inst = await Resource.get(resource.id);
|
||||||
|
await inst.update({ metadata: meta });
|
||||||
|
|
||||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ beforeAll(async () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
hostId = host.body.results.id;
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||||
|
|
||||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||||
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||||
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
// Same as the top-level setup: step out of the auto-created groups the
|
// Same as the top-level setup: step out of the auto-created groups the
|
||||||
// creator is seeded into, or this is a request for access already held.
|
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
// or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||||
await request(app)
|
await request(app)
|
||||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
.set('auth-token', token);
|
.set('auth-token', token);
|
||||||
|
|||||||
+29
-20
@@ -12,11 +12,13 @@ const {
|
|||||||
GOD_ADMIN,
|
GOD_ADMIN,
|
||||||
} = require('../utils/groups');
|
} = require('../utils/groups');
|
||||||
|
|
||||||
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
|
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
|
||||||
// prefix, services/apps are stored bare. The group-model builders use these
|
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
|
||||||
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
|
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
|
||||||
|
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
|
||||||
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
|
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
|
||||||
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
@@ -30,9 +32,13 @@ describe('slugify', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('group cn builders', () => {
|
describe('group cn builders', () => {
|
||||||
test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => {
|
test('per-resource names the kind + name slug (docs §2)', () => {
|
||||||
expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access');
|
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||||
|
});
|
||||||
|
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
|
||||||
|
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||||
|
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
|
||||||
});
|
});
|
||||||
test('aggregate uses the plural kind', () => {
|
test('aggregate uses the plural kind', () => {
|
||||||
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
@@ -42,15 +48,13 @@ describe('group cn builders', () => {
|
|||||||
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
});
|
});
|
||||||
test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => {
|
test('a directory site slug with a kind prefix is kept verbatim', () => {
|
||||||
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
|
|
||||||
// site (`site_local` -> `site-local`) would corrupt the delimiter.
|
|
||||||
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||||
expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
|
||||||
});
|
});
|
||||||
test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => {
|
test('invalid kind throws', () => {
|
||||||
|
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||||
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,32 +93,37 @@ describe('hasPermission — inheritance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('specific host group grants only that host', () => {
|
test('specific host group grants only that host', () => {
|
||||||
const cn = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin implies access; access does not imply admin', () => {
|
test('admin implies access; access does not imply admin', () => {
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true);
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false);
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('capabilities are exact — admin does not grant a capability', () => {
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
// aggregate capability
|
|
||||||
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hosts and apps are orthogonal namespaces', () => {
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
const appAdmin = resourceGroupCns('main-office', 'emby', 'admin');
|
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||||
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a service maps to the app kind (docs §11)', () => {
|
||||||
|
// The directory `service` kind is the group model's `app`.
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('cross-site isolation', () => {
|
test('cross-site isolation', () => {
|
||||||
const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-17
@@ -41,17 +41,23 @@ function assertKind(kind) {
|
|||||||
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_{slug}_{level} — the per-resource group for one resource.
|
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
|
||||||
//
|
// `theta-env`), leaving the resource's name slug. Services are stored bare
|
||||||
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
|
// (`sso-manager`), so this is a no-op for them.
|
||||||
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
|
function resourceNameSlug(slug) {
|
||||||
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
|
return String(slug || '').replace(/^(site|host|app)_/, '');
|
||||||
// yields `site_local_host_theta-env_access`. Services are stored without a
|
}
|
||||||
// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the
|
|
||||||
// convention the auto-provisioner, the resolver, and the access-request tests
|
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
|
||||||
// all share -- re-slugifying or inserting a kind would double the delimiter.
|
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
|
||||||
function resourceGroupCns(site, slug, level) {
|
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
|
||||||
return `${site}_${slug}_${level}`;
|
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
|
||||||
|
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
|
||||||
|
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
|
||||||
|
// `site_local_app_sso-manager_access`.
|
||||||
|
function resourceGroupCns(site, kind, nameSlug, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
@@ -94,11 +100,13 @@ function levelGrants(level, wanted) {
|
|||||||
// granted groups (see permission.onResource). This keeps the function pure over
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
// the user's membership only.
|
// the user's membership only.
|
||||||
function hasPermission(memberOf, resource, level) {
|
function hasPermission(memberOf, resource, level) {
|
||||||
// `site` and `slug` are used verbatim (resource slugs may carry a kind prefix,
|
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
|
||||||
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
|
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
|
||||||
|
// apps); `nameSlug` is the resource name with any kind prefix stripped.
|
||||||
const site = resource && resource.site;
|
const site = resource && resource.site;
|
||||||
const kind = resource && resource.kind;
|
const rawKind = resource && resource.kind;
|
||||||
const slug = resource && resource.slug;
|
const kind = rawKind === 'service' ? 'app' : rawKind;
|
||||||
|
const nameSlug = resourceNameSlug(resource && resource.slug);
|
||||||
const set = new Set(memberOf || []);
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
if (set.has(GOD_ADMIN)) return true;
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
@@ -107,13 +115,13 @@ function hasPermission(memberOf, resource, level) {
|
|||||||
if (isKnownLevel(level)) {
|
if (isKnownLevel(level)) {
|
||||||
// admin / access
|
// admin / access
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Opaque capability — exact aggregate or specific grant only.
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +130,7 @@ module.exports = {
|
|||||||
KNOWN_LEVELS,
|
KNOWN_LEVELS,
|
||||||
KINDS,
|
KINDS,
|
||||||
slugify,
|
slugify,
|
||||||
|
resourceNameSlug,
|
||||||
resourceGroupCns,
|
resourceGroupCns,
|
||||||
aggregateGroupCns,
|
aggregateGroupCns,
|
||||||
siteSuperAdminCns,
|
siteSuperAdminCns,
|
||||||
|
|||||||
@@ -410,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List the minted external-app tokens (metadata only — the token itself is shown
|
||||||
|
// once at mint and never stored; the accessor is a renewal/revoke handle and is
|
||||||
|
// never exposed). Lets the Apps tab show what has been minted instead of a
|
||||||
|
// credential vanishing into the void.
|
||||||
|
mintAppRouter.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const rows = await VaultAppToken.list();
|
||||||
|
res.json({ apps: rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
createdBy: r.created_by,
|
||||||
|
createdOn: r.created_on,
|
||||||
|
lastRenewedAt: r.lastRenewedAt || null,
|
||||||
|
lastError: r.lastError || null,
|
||||||
|
})) });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOrCreateUserToken,
|
getOrCreateUserToken,
|
||||||
getOrCreateAdminToken,
|
getOrCreateAdminToken,
|
||||||
|
|||||||
@@ -1651,15 +1651,28 @@
|
|||||||
discoveryPlugins.forEach(p => {
|
discoveryPlugins.forEach(p => {
|
||||||
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||||
|
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
|
||||||
|
// lastError/lastLog) but was dropped here; show it so a plugin that errors
|
||||||
|
// is visible without digging into logs.
|
||||||
|
const runOk = p.lastStatus === 'ok';
|
||||||
|
const runErr = p.lastStatus === 'error';
|
||||||
|
const runState = p.lastRunAt
|
||||||
|
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
|
||||||
|
: '<span class="text-muted">Never run</span>';
|
||||||
|
const logsBtn = (p.lastLog || p.lastError)
|
||||||
|
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
|
||||||
|
: '';
|
||||||
const card = `
|
const card = `
|
||||||
<div class="card mb-3 border shadow-sm">
|
<div class="card mb-3 border shadow-sm">
|
||||||
<div class="card-body d-flex align-items-center justify-content-between">
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||||
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
<div class="small">Last run: ${runState}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
<button class="btn btn-sm btn-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-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>
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
@@ -1671,6 +1684,30 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
|
||||||
|
function fmtRunTs(ts) {
|
||||||
|
if (!ts) return 'Never run';
|
||||||
|
const m = moment(ts);
|
||||||
|
return m.isValid() ? m.fromNow() : 'Never run';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal showing the discovery plugin's last run log + error (from the plugins
|
||||||
|
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
|
||||||
|
// <pre> rather than a toast.
|
||||||
|
function showPluginLog(id) {
|
||||||
|
const p = discoveryPlugins.find(x => x.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
const body = p.lastError
|
||||||
|
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
|
||||||
|
: '';
|
||||||
|
const log = p.lastLog || '(no log captured for this run)';
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Run log — ' + (p.name || p.slug),
|
||||||
|
size: 'lg',
|
||||||
|
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleDiscoveryPlugin(id, state) {
|
async function toggleDiscoveryPlugin(id, state) {
|
||||||
const endpoint = state ? 'load' : 'unload';
|
const endpoint = state ? 'load' : 'unload';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,8 +17,11 @@
|
|||||||
<div class="tab-pane fade show active" id="tab-secrets">
|
<div class="tab-pane fade show active" id="tab-secrets">
|
||||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a href="/docs/vault" class="text-reset" title="Vault help & documentation"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -88,6 +91,20 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/<name>/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
|
||||||
|
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,12 +422,44 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
document.getElementById('app-token').textContent = result.token;
|
document.getElementById('app-token').textContent = result.token;
|
||||||
document.getElementById('app-name-display').textContent = name;
|
document.getElementById('app-name-display').textContent = name;
|
||||||
document.getElementById('app-result-card').classList.remove('d-none');
|
document.getElementById('app-result-card').classList.remove('d-none');
|
||||||
|
loadApps();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
errorEl.classList.remove('d-none');
|
errorEl.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List the minted external-app tokens (metadata only). Makes the Apps tab show
|
||||||
|
// what's been minted instead of a credential that vanishes after the once-only
|
||||||
|
// token display.
|
||||||
|
async function loadApps() {
|
||||||
|
const $list = document.getElementById('apps-list');
|
||||||
|
if (!$list) return;
|
||||||
|
$list.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/vault/apps', {
|
||||||
|
headers: { 'auth-token': app.auth.getToken() }
|
||||||
|
});
|
||||||
|
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
|
||||||
|
const { apps = [] } = await res.json();
|
||||||
|
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
|
||||||
|
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
|
||||||
|
const ok = !a.lastError;
|
||||||
|
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
||||||
|
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
||||||
|
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
||||||
|
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
||||||
|
</div>
|
||||||
|
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') + '</div>';
|
||||||
|
} catch (err) {
|
||||||
|
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyText(text) {
|
function copyText(text) {
|
||||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||||
}
|
}
|
||||||
@@ -572,6 +621,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
|||||||
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
||||||
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
||||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||||
|
loadApps();
|
||||||
}
|
}
|
||||||
loadSecrets();
|
loadSecrets();
|
||||||
loadShared();
|
loadShared();
|
||||||
|
|||||||
Reference in New Issue
Block a user