Files
picpeak/backend/src/routes/adminWebhooks.js
T
Luca b118695474 feat(permissions): granular permission gating + role editor & presets (#747, phase 1 of #743) (#1045)
* feat(permissions): granular permission gating + role editor & presets

Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).

- Split the catch-all settings.edit into dedicated dangerous-config perms
  (banking / domains / security / integrations / features): a team member
  can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
  flags. Reads keep an OR with settings.view so existing roles keep
  visibility. The site-URL write inside /general is change-gated on
  settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
  (whatsapp, event_types, image_security, notifications, system) plus
  roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
  permission (tracks-all) so new perms never need a compensation
  migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
  Photographer (contributor — view events + manage photos + read-only CRM;
  no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
  matrix; system roles protected, super_admin immutable) and a Roles tab
  with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.

Migration 174. Backward-compatible: admin/editor/viewer unchanged.

* feat(permissions): hide in-page action buttons a role can't use

Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:

- Events list: create / bulk archive / bulk delete / row archive /
  row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
  (photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
  (events.create), archive (events.archive), create-invoice
  (bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
  (photos.download).

Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.

* fix(permissions): close settings-split bypass via generic settings writers

Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.

Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.

Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
  so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
  and the test renders without a PermissionsProvider).

* fix(permissions): address upstream review (#1045)

- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
  from #1035; the collision made picpeakImportService's forward-only restore
  guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
  non-super_admin can no longer edit their own role, nor grant any permission
  their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
  silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
  replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
  gates (whatsapp/event_types/image_security/notifications/system), matching the
  settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.

* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)

Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
  general_site_url on every save, so a settings.edit-only role (the office
  manager this PR enables) got 403'd on every General save even when the URL
  was unchanged. Restore change-detection: compare the incoming value against
  the stored one and 403 only on an actual change; unchanged protected keys are
  dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
  adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
  its own role, can't escalate another role; super_admin bypasses;
  team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
  unchanged general_site_url saves, an actual change 403s, super_admin changes it.
2026-08-16 14:59:52 +02:00

394 lines
16 KiB
JavaScript

/**
* Admin endpoints for managing outbound webhooks (#327). Mirrors
* adminApiTokens.js — same permission gates, same "secret shown once"
* pattern.
*
* Routes mounted under /api/admin/webhooks:
* GET / — list
* POST / — create (returns plaintext secret once)
* GET /:id — detail (no secret)
* PUT /:id — update name/url/events/active
* DELETE /:id — delete (cascades to deliveries)
* POST /:id/test — fire a synthetic delivery now
* GET /:id/deliveries — list deliveries (paginated, filter)
* GET /:id/deliveries/:deliveryId — delivery detail (payload+response)
* POST /:id/deliveries/:deliveryId/replay — re-enqueue a delivery
*/
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
// Migration 174: webhooks are an integration surface. Mutations require the
// dedicated `settings.integrations` perm (split out of the old catch-all
// `settings.edit`); reads allow either the general `settings.view` or the
// integration perm so a role scoped to integrations can still read them.
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
const router = express.Router();
const ALLOW_PRIVATE_URLS = process.env.WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
function publicWebhook(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
url: row.url,
events: typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []),
active: row.active,
secret_preview: row.secret_preview,
filter: typeof row.filter === 'string' ? safeJson(row.filter, {}) : (row.filter || {}),
template: row.template || null,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
last_success_at: row.last_success_at,
last_failure_at: row.last_failure_at,
};
}
function safeJson(s, fallback) {
try { return JSON.parse(s); } catch { return fallback; }
}
// ─── List ────────────────────────────────────────────────────────────────
router.get('/', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => {
try {
const rows = await db('webhooks')
.leftJoin('admin_users', 'admin_users.id', 'webhooks.created_by')
.select(
'webhooks.*',
'admin_users.username as owner_username'
)
.orderBy('webhooks.created_at', 'desc');
res.json(rows.map((r) => ({
...publicWebhook(r),
owner_username: r.owner_username,
})));
} catch (err) {
logger.error('webhooks list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list webhooks' });
}
});
// ─── Create ──────────────────────────────────────────────────────────────
router.post(
'/',
adminAuth,
requirePermission('settings.integrations'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { name, url, events, active = true, filter, template } = req.body;
const { plaintext, preview } = webhookService.generateSecret();
const insertResult = await db('webhooks').insert({
name,
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active,
filter: JSON.stringify(filter || {}),
template: template || null,
created_by: req.admin.id,
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
await logActivity('webhook_created', { name, events }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
const row = await db('webhooks').where({ id }).first();
res.status(201).json({
...publicWebhook(row),
secret: plaintext,
notice: 'Save this signing secret now — it will not be shown again.',
});
} catch (err) {
logger.error('webhooks create failed', { error: err.message });
res.status(500).json({ error: 'Failed to create webhook' });
}
}
);
// ─── Detail ──────────────────────────────────────────────────────────────
router.get('/:id', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
res.json(publicWebhook(row));
} catch (err) {
logger.error('webhooks detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load webhook' });
}
});
// ─── Update ──────────────────────────────────────────────────────────────
router.put(
'/:id',
adminAuth,
requirePermission('settings.integrations'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').optional().isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
const updates = { updated_at: new Date() };
if ('name' in req.body) updates.name = req.body.name;
if ('url' in req.body) updates.url = req.body.url;
if ('events' in req.body) updates.events = JSON.stringify(req.body.events);
if ('active' in req.body) updates.active = req.body.active;
if ('filter' in req.body) updates.filter = JSON.stringify(req.body.filter || {});
if ('template' in req.body) updates.template = req.body.template || null;
await db('webhooks').where({ id: req.params.id }).update(updates);
const updated = await db('webhooks').where({ id: req.params.id }).first();
await logActivity('webhook_updated', { changes: Object.keys(updates) }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json(publicWebhook(updated));
} catch (err) {
logger.error('webhooks update failed', { error: err.message });
res.status(500).json({ error: 'Failed to update webhook' });
}
}
);
// ─── Delete ──────────────────────────────────────────────────────────────
router.delete('/:id', adminAuth, requirePermission('settings.integrations'), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
await db('webhooks').where({ id: req.params.id }).delete();
await logActivity('webhook_deleted', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json({ id: Number(req.params.id), deleted: true });
} catch (err) {
logger.error('webhooks delete failed', { error: err.message });
res.status(500).json({ error: 'Failed to delete webhook' });
}
});
// ─── Send test event ─────────────────────────────────────────────────────
router.post(
'/:id/test',
adminAuth,
requirePermission('settings.integrations'),
[body('event_type').optional().isIn(webhookService.EVENT_TYPES)],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
if (!row.active) return res.status(400).json({ error: 'Webhook is disabled' });
const eventType = req.body.event_type || (() => {
const subscribed = typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []);
return subscribed[0] || 'event.published';
})();
// Fire a synthetic event WITHOUT writing to webhooks table — the test
// bypasses subscription matching by inserting a delivery directly.
const crypto = require('crypto');
const deliveryId = crypto.randomUUID();
const payload = {
id: deliveryId,
type: eventType,
created_at: new Date().toISOString(),
data: { test: true, fired_by: req.admin.username, webhook_id: row.id },
};
await db('webhook_deliveries').insert({
webhook_id: row.id,
event_type: eventType,
payload: JSON.stringify(payload),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
res.status(202).json({ enqueued: true, event_type: eventType });
} catch (err) {
logger.error('webhook test failed', { error: err.message });
res.status(500).json({ error: 'Failed to enqueue test event' });
}
}
);
// ─── List deliveries ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries',
adminAuth,
requirePermission(['settings.view', 'settings.integrations']),
[
query('status').optional().isIn(['pending', 'success', 'failed']),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const webhookId = req.params.id;
const exists = await db('webhooks').where({ id: webhookId }).first();
if (!exists) return res.status(404).json({ error: 'Webhook not found' });
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '25', 10);
const offset = (page - 1) * limit;
let q = db('webhook_deliveries').where({ webhook_id: webhookId });
if (req.query.status) q = q.where({ status: req.query.status });
const totalRow = await q.clone().count('id as count').first();
const total = parseInt(totalRow?.count || 0, 10);
const rows = await q
.select(
'id', 'event_type', 'attempt_count', 'status', 'response_status',
'latency_ms', 'next_retry_at', 'created_at', 'completed_at', 'last_error'
)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset);
res.json({ deliveries: rows, pagination: { page, limit, total } });
} catch (err) {
logger.error('deliveries list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list deliveries' });
}
}
);
// ─── Delivery detail ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries/:deliveryId',
adminAuth,
requirePermission(['settings.view', 'settings.integrations']),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
res.json({
...row,
payload: typeof row.payload === 'string' ? safeJson(row.payload, row.payload) : row.payload,
});
} catch (err) {
logger.error('delivery detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load delivery' });
}
}
);
// ─── Replay ──────────────────────────────────────────────────────────────
router.post(
'/:id/deliveries/:deliveryId/replay',
adminAuth,
requirePermission('settings.integrations'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
// Re-enqueue: copy the original payload + event_type into a new row
// marked pending. Preserves the audit log of the original attempt.
const crypto = require('crypto');
const newPayload = (() => {
const obj = typeof row.payload === 'string' ? safeJson(row.payload, {}) : row.payload || {};
// Replays get a fresh delivery id but keep the event payload data.
return JSON.stringify({ ...obj, id: crypto.randomUUID(), replayed_from: row.id });
})();
const insertResult = await db('webhook_deliveries').insert({
webhook_id: row.webhook_id,
event_type: row.event_type,
payload: newPayload,
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
}).returning('id');
const newId = insertResult[0]?.id || insertResult[0];
res.status(202).json({ enqueued: true, original_id: row.id, replay_id: newId });
} catch (err) {
logger.error('delivery replay failed', { error: err.message });
res.status(500).json({ error: 'Failed to replay delivery' });
}
}
);
module.exports = router;