'use strict'; // Plugin instances API — the loadable, configurable, multi-copy plugin system. // // Replaces the old routes/plugins.js (which only toggled cron/enabled on static // config via a Redis hash). Here every plugin is a PluginInstance row (see // models/plugin_instance.js) with its own schedule and its secrets in OpenBao // (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API. // // Gated router-wide to the same admin groups as the directory admin API, so // existing directory admins keep access. Secrets are never returned in // cleartext — only masked (`********`) — and never persisted in the DB. const router = require('express').Router(); const permission = require('../utils/permission'); const registry = require('../services/plugin_registry'); const pluginSecrets = require('../utils/plugin_secrets'); const { PluginInstance, STATUS } = require('../models/plugin_instance'); const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler'); const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; // Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin // (app_super_admin is always allowed by permission.byGroup). router.use(async (req, res, next) => { try { await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']); next(); } catch (err) { next(err); } }); // Plain object for the wire, with masked secret values attached under // `secrets` and the run-state fields surfaced. The DB row never holds secrets. async function serialize(instance) { const obj = instance.toJSON ? instance.toJSON() : { ...instance }; const secrets = await pluginSecrets.read(instance.id).catch(() => ({})); obj.secrets = registry.mask(instance.pluginType, secrets); return obj; } // Validate a create/update payload against a plugin type's configSchema. // Returns an error string or null. `flat` is the merged config + secret values // (the UI sends one flat object; the API splits it). function validateFields(type, flat) { const required = registry.requiredKeys(type); for (const key of required) { const v = flat && flat[key]; if (v === undefined || v === null || v === '') { return `Missing required field: ${key}`; } } return null; } // --- Plugin types (for the create-instance picker + form) --- router.get('/types', (req, res) => { res.json({ results: registry.getTypes() }); }); // --- List instances --- router.get('/', async (req, res, next) => { try { const instances = await PluginInstance.list(); const out = []; for (const inst of instances) out.push(await serialize(inst)); res.json({ results: out }); } catch (err) { next(err); } }); router.get('/:id', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); res.json({ results: await serialize(inst) }); } catch (err) { next(err); } }); // --- Create instance --- router.post('/', async (req, res, next) => { try { const { pluginType, name, slug, cron } = req.body; if (!pluginType) return res.status(400).json({ error: 'pluginType is required' }); if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` }); if (!name) return res.status(400).json({ error: 'name is required' }); if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' }); if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' }); // `config` from the client is a flat object of all field values (secret + // non-secret). Split it: non-secret -> DB, secret -> OpenBao. const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {}; const fieldErr = validateFields(pluginType, flat); if (fieldErr) return res.status(400).json({ error: fieldErr }); const manifest = registry.getManifest(pluginType); const { config, secrets } = registry.splitConfig(pluginType, flat); const enabled = req.body.enabled !== false; // default true const now = Date.now(); const instance = await PluginInstance.create({ pluginType, category: manifest.category, name, slug, enabled, cron: cron || '0 * * * *', config, created_by: req.user.uid, created_on: now, updated_by: req.user.uid, updated_on: now }); try { await pluginSecrets.write(instance.id, secrets); } catch (err) { // Most likely the sso-broker policy lacks secret/plugins/* — the // operator needs theta-suite >= v1.30.1. Delete the row so a failed // secret write doesn't strand a half-created instance. await instance.delete().catch(() => {}); return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` }); } if (enabled) { await scheduleInstance(instance); await runInstanceNow(instance.id); } res.json({ results: await serialize(instance) }); } catch (err) { if (err.name === 'SequelizeUniqueConstraintError') { return res.status(400).json({ error: 'A plugin instance with this slug already exists.' }); } next(err); } }); // --- Update instance (name/cron/enabled/non-secret config) --- router.put('/:id', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` }); const updates = {}; if (req.body.name !== undefined) updates.name = req.body.name; if (req.body.cron !== undefined) { if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' }); updates.cron = req.body.cron; } if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled; // Non-secret config: split the client's flat config so secret fields are // never written to the DB. Secrets are changed via PUT /:id/secrets. if (req.body.config !== undefined && typeof req.body.config === 'object') { const { config } = registry.splitConfig(inst.pluginType, req.body.config); updates.config = config; } updates.updated_by = req.user.uid; updates.updated_on = Date.now(); const updated = await inst.update(updates); // Re-schedule if the schedule-relevant fields moved. if (updates.cron !== undefined || updates.enabled !== undefined) { await scheduleInstance(updated); } res.json({ results: await serialize(updated) }); } catch (err) { if (err.name === 'SequelizeUniqueConstraintError') { return res.status(400).json({ error: 'A plugin instance with this slug already exists.' }); } next(err); } }); // --- Update secrets only --- router.put('/:id/secrets', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` }); // Keep only declared secret fields; pluginSecrets.write drops blank/MASK // values so an unchanged masked field is a no-op. const { secrets } = registry.splitConfig(inst.pluginType, req.body || {}); await pluginSecrets.write(inst.id, secrets); await inst.update({ updated_by: req.user.uid, updated_on: Date.now() }); res.json({ results: true }); } catch (err) { next(err); } }); // --- Test (validate) --- router.post('/:id/test', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); const mod = registry.getModule(inst.pluginType); if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' }); const cfg = await pluginSecrets.mergeForRun(inst); const result = await mod.validate(cfg); if (result && result.ok) return res.json(result); return res.status(400).json(result || { ok: false, error: 'validation failed' }); } catch (err) { return res.status(400).json({ ok: false, error: err.message }); } }); // --- Load (enable + schedule + run now) --- router.post('/:id/load', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() }); await scheduleInstance(updated); await runInstanceNow(updated.id); res.json({ results: await serialize(updated) }); } catch (err) { next(err); } }); // --- Unload (unschedule + disable) --- router.post('/:id/unload', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); await unscheduleInstance(inst.id); const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() }); res.json({ results: await serialize(updated) }); } catch (err) { next(err); } }); // --- Run now (regardless of enabled) --- router.post('/:id/run', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); await runInstanceNow(inst.id); res.json({ results: true }); } catch (err) { next(err); } }); // --- Last-run status --- router.get('/:id/runs', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } }); } catch (err) { next(err); } }); // --- Delete (unschedule + remove secrets + delete row) --- router.delete('/:id', async (req, res, next) => { try { const inst = await PluginInstance.get(req.params.id); if (!inst) return res.status(404).json({ error: 'Not found' }); await unscheduleInstance(inst.id); await pluginSecrets.remove(inst.id); // best-effort await inst.delete(); res.json({ results: true }); } catch (err) { next(err); } }); module.exports = router;