Compare commits

..

3 Commits

Author SHA1 Message Date
wmantly e8d04203c3 fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 23:00:25 -04:00
wmantly 8db00f0ed6 fix: drop legacy app_super_admin -- SUPER_ADMIN_GROUP is now god_admin (v1.26.1) (#167)
god_admin now exists at boot (seeded by docker-entrypoint), so the canonical
cross-resource super group nested into every resource's _admin group is god_admin,
not the legacy app_super_admin. docker-entrypoint no longer seeds or nests
app_super_admin (god_admin nests into the app_sso_* groups directly). isSuperAdmin
still recognizes a pre-existing app_super_admin as a migration alias until rebuild.
2026-08-04 19:31:52 -04:00
wmantly 8a9de94d24 release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166)
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)

- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0

* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)

The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
2026-08-04 19:07:51 -04:00
20 changed files with 516 additions and 105 deletions
+3
View File
@@ -19,6 +19,9 @@
!API.md
!directory_spec.md
!docs/**/*.md
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
# in Dockerfile.openldap needs these present in the build context.
!docs/images/**
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
+24
View File
@@ -1,3 +1,27 @@
# v1.27.0
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function``update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
# v1.26.1
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
# v1.26.0
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
# v1.25.0
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
+5
View File
@@ -184,6 +184,11 @@ COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
# fs.readFileSync in routes/docs.js (files missing from the image).
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
+12 -8
View File
@@ -355,7 +355,11 @@ EOF
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
# app_sso_service_account is a marker (not a permission gate) for
# non-person accounts -- see the Users page.
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
#
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
# group-inheritance lattice. It is seeded here so it exists from first boot;
# the theta-suite bootstrap puts the first admin person into it.
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
EOF
done
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
# hold those rights by membership rather than by a special case in app code.
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
# anything binding LDAP directly -- instead of only to callers that happen
# to route through utils/permission.js.
# Nest god_admin into the SSO admin groups, so god admins hold those rights
# by membership rather than by a special case in app code. This is what makes
# the privilege visible to every consumer -- SSSD, sudo, anything binding
# LDAP directly -- instead of only to callers that happen to route through
# utils/permission.js.
#
# app_sso_service_account is deliberately excluded: it is a marker for
# non-person accounts, not a permission, and nesting admins into it would
@@ -381,10 +385,10 @@ EOF
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
info "Nested god_admin into the SSO admin groups"
fi
info "LDAP directory initialized"
+18
View File
@@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar.
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## Apps tab (admin)
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
## Shared tab
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.25.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.25.0",
"version": "1.27.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.25.0",
"version": "1.27.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+6 -6
View File
@@ -8,12 +8,11 @@ const agentManager = require('../utils/agent_manager');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
return;
}
app.wss.on('connection', (ws, req) => {
// Only the WebSocket handler needs the WS server. The REST routes mounted
// below (/api/agent/*) must work regardless of the WS server state -- gating
// them on `app.wss` made them 404 whenever it wasn't initialized.
if (app.wss) {
app.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
@@ -74,6 +73,7 @@ module.exports = function initAgentWebSockets(app) {
}));
} catch (e) {}
});
} // end if (app.wss)
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
+228 -42
View File
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
const groups = require('../utils/groups');
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
// transitively in the parent. Idempotent and non-fatal: "already a member" is
// the goal state, and a missing group (e.g. app_super_admin absent on a
// the goal state, and a missing group (e.g. god_admin absent on a
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
// caller's real work.
async function nestGroup(childCn, parentCn) {
@@ -29,6 +30,160 @@ async function nestGroup(childCn, parentCn) {
}
}
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
// The directory is the single place groups are created, as a projection of the
// resource graph. These helpers materialize the group-inheritance lattice for
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
// All of them are idempotent, so calling them again for a resource a newer
// release is backfilling is a no-op.
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
// host -> host; service -> app (services/consoles are the group model's "apps");
// site gets site-level groups (handled separately); oauth/container get no
// per-resource groups (oauth clients hang off their owning service).
function groupKind(resource) {
if (resource.kind === 'host') return 'host';
if (resource.kind === 'service') return 'app';
return null;
}
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
// seeds the mandatory first member. Returns true when created.
async function ensureGroup(name, ownerDn, description) {
try {
await Group.add({ name, owner: ownerDn, description });
return true;
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`ensureGroup: failed to create ${name}:`, err);
}
return false;
}
}
// Link a group to a resource only if that link doesn't already exist. The
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
// naive create on every Directory self-heal (which runs ensureSiteGroups /
// provisionResourceGroups on each load) was accumulating duplicate links -- the
// "groups appear 3x under a resource" bug. Always check first.
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
if (existing.length) return existing[0];
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
}
// Provision the site-level groups + the aggregates the per-resource groups nest
// into. Idempotent -- called on every directory list so a site seeded by an
// older release gets its groups without a rebuild:
//
// god_admin -> {site}_super_admin
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
//
// `{site}_everyone` is created for completeness; it has implicit membership and
// is granted to a resource as a grantee, never enumerated.
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
if (!siteSlug) return;
// Link a site group to the site resource (so it shows + is member-manageable
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
// groups as member.
const link = async (cn, isAdmin) => {
if (!siteResourceId) return;
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
};
const sAdmin = groups.siteSuperAdminCns(siteSlug);
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
await link(sAdmin, true);
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
// them), but are NOT linked to the site resource: a site carries only the god
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
// aggregates have no modal home; site-wide access is granted via S_super_admin
// and per-resource access via the host/app groups.
for (const kind of ['host', 'app']) {
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
}
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
await link(groups.siteEveryoneCns(siteSlug), false);
// god_admin is the global group; surface it on the site modal so its members
// can be managed from the Directory (it has no home on a single resource).
await link(groups.GOD_ADMIN, true);
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
// binding directly) resolve it transitively, not just utils/permission.js.
// nestGroup(child, parent) makes child a member of parent -- membership flows
// child -> parent ("up"), so a group's members inherit what its parents hold.
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
for (const kind of ['host', 'app']) {
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
}
}
// Provision the per-resource groups for a host/app and nest them into the site
// aggregates (so a site/aggregate admin reaches this resource by membership).
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
//
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
const nameSlug = groups.resourceNameSlug(resource.slug);
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
// Link both groups to the resource so the Directory can show/revoke them.
await ensureResourceGroup(resource.id, accessCn, 'member');
await ensureResourceGroup(resource.id, adminCn, 'owner');
await nestGroup(adminCn, accessCn); // administering implies using
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
}
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
// §2/§3). This is what "force the correct naming convention" means: a group
// linked to a resource must be one that parses for consumers -- the resource's
// own specific groups, its site's aggregates, site-level groups, or the global
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
// capability groups following the same shapes.
function validGroupCnsForResource(resource, siteSlug) {
const valid = new Set();
// A site resource only carries god_admin (added by the route) + the site-wide
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
// specific groups belong to host/app resources, not to the site.
if (resource.kind === 'site') {
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
}
const kind = groupKind(resource); // 'host'|'app'|null
if (kind) {
const nameSlug = groups.resourceNameSlug(resource.slug);
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
}
// oauth/container etc. — only the global god_admin makes sense to pin here.
valid.add(groups.siteSuperAdminCns(siteSlug));
return { valid, capRe: null };
}
// Require the admin group
router.use(async (req, res, next) => {
try {
@@ -50,6 +205,36 @@ router.get('/resources', async (req, res, next) => {
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
// aggregates, and every host/app resource has its per-resource groups nested
// into them. Idempotent, so this is a cheap no-op once present -- it's what
// backfills a directory seeded by an older release without a rebuild.
// Never fails the list.
const sites = resources.filter(r => r.kind === 'site');
await Promise.all(sites.map(site =>
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
));
const siteByResource = new Map();
for (const site of sites) siteByResource.set(site.id, site.slug);
const siteOf = async (r) => {
const direct = siteByResource.get(r.id);
if (direct) return direct;
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
};
await Promise.all(resources.map(async (r) => {
const gKind = groupKind(r);
if (!gKind) return;
const siteSlug = await siteOf(r);
if (!siteSlug) return;
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
}));
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
@@ -95,46 +280,25 @@ router.post('/resources', async (req, res, next) => {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
const createGroup = async (suffix, accessLevel) => {
const cn = groupCn(suffix);
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
// Wire up the two standing relationships every resource has, as nesting
// rather than as membership that has to be maintained per resource:
//
// app_super_admin -> <slug>_admin cross-app super admins administer
// every resource, automatically
// <slug>_admin -> <slug>_access administering something implies
// being able to use it
//
// Before nesting, both of these could only be expressed by adding every
// super admin to every new group by hand -- which nobody does, so the
// groups drifted. A failure here must not fail resource creation: the
// resource and its groups already exist and the nesting is repairable.
await nestGroup(groupCn('admin'), groupCn('access'));
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
// Materialize the group-model for the new resource. Site resources get the
// site-level groups; host/app resources get their per-resource groups nested
// into the site aggregates. Idempotent -- safe for a resource created by an
// older release. A provisioning failure must not fail resource creation: the
// resource already exists and the groups are repairable (re-run ensures them).
//
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
// the kind prefix themselves.
const gKind = groupKind(r);
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
if (r.kind === 'site') {
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
} else if (gKind && ancestorSite) {
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
@@ -265,7 +429,29 @@ router.get('/groups', async (req, res, next) => {
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
const { resourceId, groupCn } = req.body;
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
// be a valid group for this resource; reject free-form names so the groups
// consumers read are always parseable. god_admin is always allowed (it is
// the global group and is managed from a site's modal).
const resource = await Resource.get(resourceId);
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
// site resource's own slug is its site; a host/app uses its ancestor site.
const siteSlug = resource && resource.kind === 'site'
? resource.slug
: await Resource.findAncestorSiteSlug(resourceId);
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
err.status = 400;
throw err;
}
}
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
res.json({ results: g });
} catch (err) { next(err); }
});
@@ -313,7 +499,7 @@ router.get('/access-summary', async (req, res, next) => {
//
// Counts come from the transitive closure, not from `member`. Reading the
// attribute would report only who is listed on the group, missing anyone
// who reaches it through a nested group -- and since app_super_admin is
// who reaches it through a nested group -- and since god_admin is
// nested into every resource's _admin group, that is not an edge case.
let members = [];
if (group) {
+8 -3
View File
@@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant');
const vaultBroker = require('../utils/vault_broker');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
// only reject values that can't be a sane secret path segment (spaces, slashes,
// leading non-alnum, too long).
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const router = express.Router();
@@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => {
if (byId.has(g.id)) continue; // already owner
byId.set(g.id, { role: 'grantee', ...g });
}
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
// The `{ role, ...s }` spread above copies only own properties, so the
// instance method `path()` is dropped -- call the static builder instead.
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
} catch (e) { next(e); }
});
@@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const slug = String(req.body.slug || '').trim().toLowerCase();
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
const description = String(req.body.description || '').trim();
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
+5 -2
View File
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await Resource.update(resource.id, { metadata: meta });
// `Resource.update` is not a static — `update` is an instance method
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
const inst = await Resource.get(resource.id);
await inst.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
+1 -1
View File
@@ -55,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
// only resolves correctly on GitHub. Serve that same folder here and rewrite
// the rendered markup to point at it absolutely, so the images work when
// read from /docs/overview too.
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
+7 -5
View File
@@ -44,9 +44,10 @@ beforeAll(async () => {
expect(host.status).toBe(200);
hostId = host.body.results.id;
// Creating a host auto-provisions <site>_<slug>_access / _admin.
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
// (docs/GROUPS.md §2 — the kind is part of the name).
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
// The creator is seeded into both groups -- groupOfNames requires at least
// one member, so Group.add puts the owner's DN there -- and _admin is nested
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
expect(host.status).toBe(200);
// Same as the top-level setup: step out of the auto-created groups the
// creator is seeded into, or this is a request for access already held.
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
// or this is a request for access already held.
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
await request(app)
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
.set('auth-token', token);
+26 -10
View File
@@ -12,9 +12,14 @@ const {
GOD_ADMIN,
} = require('../utils/groups');
const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' };
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' };
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
describe('slugify', () => {
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
@@ -24,27 +29,33 @@ describe('slugify', () => {
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
expect(slugify('')).toBe('');
});
test('never contains an underscore (the structural delimiter)', () => {
expect(slugify('a_b_c')).not.toContain('_');
expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__');
});
});
describe('group cn builders', () => {
test('per-resource uses singular kind', () => {
test('per-resource names the kind + name slug (docs §2)', () => {
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
});
test('aggregate uses plural kind', () => {
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
});
test('aggregate uses the plural kind', () => {
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
});
test('site super admin + everyone', () => {
expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin');
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
});
test('a directory site slug with a kind prefix is kept verbatim', () => {
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
});
test('invalid kind throws', () => {
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
});
});
@@ -95,7 +106,6 @@ describe('hasPermission — inheritance', () => {
test('capabilities are exact — admin does not grant a capability', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
// aggregate capability
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
});
@@ -106,6 +116,12 @@ describe('hasPermission — inheritance', () => {
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
});
test('a service maps to the app kind (docs §11)', () => {
// The directory `service` kind is the group model's `app`.
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
});
test('cross-site isolation', () => {
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
+29 -11
View File
@@ -41,26 +41,39 @@ function assertKind(kind) {
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
}
// {site}_host_<slug>_<level> / {site}_app_<slug>_<level>
function resourceGroupCns(site, kind, slug, level) {
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
// `theta-env`), leaving the resource's name slug. Services are stored bare
// (`sso-manager`), so this is a no-op for them.
function resourceNameSlug(slug) {
return String(slug || '').replace(/^(site|host|app)_/, '');
}
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
// `site_local_app_sso-manager_access`.
function resourceGroupCns(site, kind, nameSlug, level) {
assertKind(kind);
return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`;
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
}
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
function aggregateGroupCns(site, kind, level) {
assertKind(kind);
return `${slugify(site)}_${kind}s_${level}`;
return `${site}_${kind}s_${level}`;
}
// {site}_super_admin
function siteSuperAdminCns(site) {
return `${slugify(site)}_super_admin`;
return `${site}_super_admin`;
}
// {site}_everyone
function siteEveryoneCns(site) {
return `${slugify(site)}_everyone`;
return `${site}_everyone`;
}
// True if `level` is a known admin/access level (not an opaque capability).
@@ -87,9 +100,13 @@ function levelGrants(level, wanted) {
// granted groups (see permission.onResource). This keeps the function pure over
// the user's membership only.
function hasPermission(memberOf, resource, level) {
const site = slugify(resource && resource.site);
const kind = resource && resource.kind;
const slug = slugify(resource && resource.slug);
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
// apps); `nameSlug` is the resource name with any kind prefix stripped.
const site = resource && resource.site;
const rawKind = resource && resource.kind;
const kind = rawKind === 'service' ? 'app' : rawKind;
const nameSlug = resourceNameSlug(resource && resource.slug);
const set = new Set(memberOf || []);
if (set.has(GOD_ADMIN)) return true;
@@ -98,13 +115,13 @@ function hasPermission(memberOf, resource, level) {
if (isKnownLevel(level)) {
// admin / access
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
return false;
}
// Opaque capability — exact aggregate or specific grant only.
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
return false;
}
@@ -113,6 +130,7 @@ module.exports = {
KNOWN_LEVELS,
KINDS,
slugify,
resourceNameSlug,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
+8 -7
View File
@@ -4,13 +4,14 @@ const {Group} = require('../models/group_ldap');
const groups = require('./groups');
// The group nested into every resource's _admin group by api_directory_admin
// (cross-resource super-admin administration). KEEP the legacy `app_super_admin`
// here: it is the group that actually exists and gets nested. The new schema's
// global `god_admin` is recognized in isSuperAdmin() below, and api_directory_admin
// nests SUPER_ADMIN_GROUP -- so until `god_admin` is created during bootstrap, this
// must stay `app_super_admin` or resource auto-provisioning's nesting silently
// no-ops (leaving only the creator as the group's sole member).
const SUPER_ADMIN_GROUP = 'app_super_admin';
// (cross-resource super-admin administration). This is `god_admin` -- the global
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
// It used to be the legacy `app_super_admin`, which existed while god_admin
// didn't; now that god_admin is created at boot, the provisioning nests it.
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
// the migration, so an existing deployment isn't stripped of rights until it's
// rebuilt.
const SUPER_ADMIN_GROUP = 'god_admin';
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
// True if the user (by resolved member cns) is a global god/super admin.
+21
View File
@@ -410,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
}
});
// List the minted external-app tokens (metadata only — the token itself is shown
// once at mint and never stored; the accessor is a renewal/revoke handle and is
// never exposed). Lets the Apps tab show what has been minted instead of a
// credential vanishing into the void.
mintAppRouter.get('/', async (req, res, next) => {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const rows = await VaultAppToken.list();
res.json({ apps: rows.map((r) => ({
name: r.name,
createdBy: r.created_by,
createdOn: r.created_on,
lastRenewedAt: r.lastRenewedAt || null,
lastError: r.lastError || null,
})) });
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
next(e);
}
});
module.exports = {
getOrCreateUserToken,
getOrCreateAdminToken,
+59 -4
View File
@@ -530,6 +530,11 @@
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByToken = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
// host red as if it lacked an agent.
var agentsUnavailable = false;
async function loadResources() {
try {
@@ -540,8 +545,12 @@
// Access counts are a nicety, not load-bearing: if the LDAP join fails
// the table still renders, just without the Access column populated.
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
// Agents are a nicety too: never block the directory on them.
app.api.get('agent/nodes').catch(function(){ return {agents: []}; })
// Agents are a nicety too: never block the directory on them. Track
// whether the endpoint itself is reachable so host rows can tell "no
// agent on this host" from "agent service is down" (see attachAgentStatus).
app.api.get('agent/nodes')
.then(function(res){ agentsUnavailable = false; return res; })
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
]);
accessSummary = (resAccess && resAccess.results) || {};
@@ -610,7 +619,12 @@
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; }
if (!a) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
}
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
const t = a.telemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
@@ -655,8 +669,12 @@
try {
const res = await app.api.get('agent/nodes');
indexAgents((res && res.agents) || []);
agentsUnavailable = false;
renderTable();
} catch (e) { /* non-fatal */ }
} catch (e) {
agentsUnavailable = true;
renderTable(); // re-render so dots flip to neutral, not stale green
}
}
// "Who can reach this?" at a glance. A resource with no linked group is not a
@@ -1633,15 +1651,28 @@
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
// lastError/lastLog) but was dropped here; show it so a plugin that errors
// is visible without digging into logs.
const runOk = p.lastStatus === 'ok';
const runErr = p.lastStatus === 'error';
const runState = p.lastRunAt
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
: '<span class="text-muted">Never run</span>';
const logsBtn = (p.lastLog || p.lastError)
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
: '';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
<div class="small">Last run: ${runState}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
${logsBtn}
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
@@ -1653,6 +1684,30 @@
});
}
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
function fmtRunTs(ts) {
if (!ts) return 'Never run';
const m = moment(ts);
return m.isValid() ? m.fromNow() : 'Never run';
}
// Modal showing the discovery plugin's last run log + error (from the plugins
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
// <pre> rather than a toast.
function showPluginLog(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
const body = p.lastError
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
: '';
const log = p.lastLog || '(no log captured for this run)';
app.modal.open({
title: 'Run log — ' + (p.name || p.slug),
size: 'lg',
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
+2 -2
View File
@@ -657,8 +657,8 @@
</script>
<div id="own-api-tokens-section" style="display:none">
<div class="row mt-3 justify-content-center">
<div class="col-md-8">
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
+51 -1
View File
@@ -17,7 +17,10 @@
<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">
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
<div class="d-flex align-items-center gap-2">
<a href="/docs/vault" class="text-reset" title="Vault help &amp; documentation"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
</div>
</div>
<div class="p-3">
<div class="row">
@@ -88,6 +91,20 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</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/&lt;name&gt;/*</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>
@@ -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-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
// List the minted external-app tokens (metadata only). Makes the Apps tab show
// what's been minted instead of a credential that vanishes after the once-only
// token display.
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.textContent = 'Loading…';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
@@ -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>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
loadApps();
}
loadSecrets();
loadShared();