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.
This commit is contained in:
Luca
2026-08-16 14:59:52 +02:00
committed by GitHub
parent 9b976386f9
commit b118695474
35 changed files with 2215 additions and 331 deletions
@@ -0,0 +1,86 @@
/**
* Role-editor self-amplification guard (migration 175 / adminRoles).
*
* `roles.manage` must be a DELEGATION primitive, not root escalation: a
* non-super_admin holder can only grant permissions their OWN role already
* holds, and can't edit their own role. super_admin bypasses. Pins
* userManagementService.createRole / updateRole (assertActorMayGrant).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-roleguard-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'roleguard-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-roleguard-storage-'));
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const svc = require('../../src/services/userManagementService');
const { clearPermissionCache } = require('../../src/middleware/permissions');
describe('role editor — self-amplification guard', () => {
let db; let cleanup;
let superId; let mgrRoleId; let mgrId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
// A non-super role that CAN manage roles but only holds a couple of perms.
const mgrRole = await svc.createRole(
{ name: 'limited_mgr', permissions: ['roles.manage', 'events.view'] },
superId,
);
mgrRoleId = mgrRole.id;
const ins = await db('admin_users').insert({
username: 'mgr', email: '[email protected]', password_hash: 'x',
role_id: mgrRoleId, must_change_password: false, created_at: new Date(),
}).returning('id');
mgrId = ins[0]?.id ?? ins[0];
clearPermissionCache();
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('super_admin can grant any permission', async () => {
const r = await svc.createRole(
{ name: 'power_role', permissions: ['settings.banking', 'users.delete'] },
superId,
);
expect(r.permissions).toEqual(expect.arrayContaining(['settings.banking', 'users.delete']));
});
it('non-super cannot grant a permission its own role lacks', async () => {
await expect(
svc.createRole({ name: 'sneaky', permissions: ['events.view', 'settings.banking'] }, mgrId),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('non-super can create a role within its own permissions', async () => {
const r = await svc.createRole({ name: 'viewer_lite', permissions: ['events.view'] }, mgrId);
expect(r.permissions).toEqual(['events.view']);
});
it('non-super cannot edit its own role', async () => {
await expect(
svc.updateRole(mgrRoleId, { permissions: ['roles.manage', 'events.view'] }, mgrId),
).rejects.toThrow(/cannot edit your own role/i);
});
it('non-super cannot escalate another role beyond its own permissions', async () => {
const adminRole = await db('roles').where({ name: 'admin' }).first();
await expect(
svc.updateRole(adminRole.id, { permissions: ['settings.banking'] }, mgrId),
).rejects.toThrow(/only grant permissions your own role/i);
});
it('the built-in team_photographer name is reserved', async () => {
await expect(
svc.createRole({ name: 'team_photographer', permissions: [] }, superId),
).rejects.toThrow(/reserved/i);
});
});
@@ -0,0 +1,95 @@
/**
* Protected-key boundary on the generic settings writers (migration 175).
*
* A role with settings.edit but NOT settings.domains (the "office manager" this
* PR enables) must be able to save the General tab — which re-posts
* general_site_url on every save — as long as the URL is UNCHANGED, and must be
* 403'd only when it actually tries to change a protected key. Regression pin for
* the change-detection fix (the presence-only check over-fired on every save).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-setkeys-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'setkeys-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-setkeys-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
const svc = require('../../src/services/userManagementService');
const { clearPermissionCache } = require('../../src/middleware/permissions');
const STORED_URL = 'https://stored.example';
describe('settings protected-key boundary (/general)', () => {
let db; let cleanup; let app;
let superTok; let mgrTok;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
const readSiteUrl = async () => {
const row = await db('app_settings').where({ setting_key: 'general_site_url' }).first();
return row ? JSON.parse(row.setting_value) : null;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
// Office-manager role: settings.view + settings.edit, NOT settings.domains.
const mgrRole = await svc.createRole(
{ name: 'office_mgr', permissions: ['settings.view', 'settings.edit'] },
superId,
);
const ins = await db('admin_users').insert({
username: 'office', email: '[email protected]', password_hash: 'x',
role_id: mgrRole.id, must_change_password: false, created_at: new Date(),
}).returning('id');
mgrTok = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('app_settings').insert({
setting_key: 'general_site_url', setting_value: JSON.stringify(STORED_URL), setting_type: 'general',
});
clearPermissionCache();
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('settings.edit role can save /general when general_site_url is unchanged', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok)
.send({ general_site_url: STORED_URL, general_max_file_size_mb: 50 });
expect(res.status).not.toBe(403);
expect(res.status).toBe(200);
expect(await readSiteUrl()).toBe(STORED_URL);
});
it('settings.edit role is 403d when it actually changes general_site_url', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), mgrTok)
.send({ general_site_url: 'https://evil.example' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('FORBIDDEN');
expect(res.body.keys.map((k) => k.key)).toContain('general_site_url');
expect(await readSiteUrl()).toBe(STORED_URL); // unchanged
});
it('super_admin can change general_site_url', async () => {
const res = await auth(request(app).put('/api/admin/settings/general'), superTok)
.send({ general_site_url: 'https://new.example' });
expect(res.status).toBe(200);
expect(await readSiteUrl()).toBe('https://new.example');
});
});
@@ -51,11 +51,13 @@ describe('authorization / ownership gaps', () => {
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.edit to the admin role BEFORE any request populates the
// 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). This models a
// custom role that carries settings.edit the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.edit');
// Grant settings.integrations to the admin role BEFORE any request populates
// the 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). Migration 174 split
// API-token management out of the catch-all settings.edit into the dedicated
// settings.integrations perm; this models a custom role that carries it —
// the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.integrations');
adminTok = mintAdminToken(adminId);
app = express();
@@ -96,7 +98,7 @@ describe('authorization / ownership gaps', () => {
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
it('a non-owner (with settings.integrations) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
@@ -0,0 +1,256 @@
/**
* Migration: Granular permissions + role presets
*
* Phase 1 of the multi-photographer epic (issues #743/#747): make every feature
* permission-gateable so multi-user studios can split capability across roles.
*
* This migration ONLY defines the permission catalog + seeds grants/presets. The
* route-level enforcement (pointing endpoints at the new perms) lives in the
* route files. Splitting the perms here is inert until a route references them.
*
* What it does:
* 1. Split the catch-all `settings.edit` into dedicated DANGEROUS-config perms
* (banking / domains / security / integrations / features) so a team member
* can be granted day-to-day settings without the ability to break IBAN,
* domains, SSO, webhooks or feature toggles. Grants project forward from
* every role that holds `settings.edit` today — nobody loses capability on
* upgrade (see feedback_permission_split_compat). Today `settings.edit` is
* super_admin-only, so in practice only super_admin gains the split perms.
* 2. Add dedicated view/manage perms for feature areas that previously borrowed
* generic perms (calendar, deals, transfers, whatsapp, tax report, vat codes,
* event types, short urls, css templates, image security, notifications,
* system) so each can be carved into a custom role independently.
* 3. Grant every new perm to `super_admin` (the tracks-all owner). The boot
* self-heal (_permissionsBoot.js) keeps super_admin complete on every future
* release, so a new perm never needs a compensation migration.
* 4. Seed the `solo_photographer` preset role — a full operator (studio owner)
* granted every current permission. It is a frozen preset: future-release
* perms are NOT auto-added (only super_admin tracks all); the owner grants
* them via the role editor if wanted.
*
* Idempotent throughout (existing-name / existing-grant checks) so a re-run or a
* partially-applied state is safe.
*/
// ---------------------------------------------------------------------------
// New DANGEROUS-config permissions split out of `settings.edit`.
// `settings.edit` is retained as the SAFE everyday bucket (branding text,
// general prefs, display formats, SEO/analytics copy, public-site content).
// ---------------------------------------------------------------------------
const SETTINGS_SPLIT_PERMISSIONS = [
{ name: 'settings.banking', display_name: 'Manage Banking & Payment Config', category: 'settings', description: 'Edit bank accounts / IBAN, QR-bill, the issuer block and VAT/accounting config. Sensitive — governs where money is collected.' },
{ name: 'settings.domains', display_name: 'Manage Domain & URL Config', category: 'settings', description: 'Edit the public base URL / domain settings used in links and emails.' },
{ name: 'settings.security', display_name: 'Manage Security & SSO Config', category: 'settings', description: 'Edit security policy, rate limits and single sign-on (OIDC) login settings.' },
{ name: 'settings.integrations', display_name: 'Manage Integrations', category: 'settings', description: 'Manage outbound webhooks and API tokens.' },
{ name: 'settings.features', display_name: 'Manage Feature Toggles', category: 'settings', description: 'Enable or disable application features (feature flags).' },
];
// ---------------------------------------------------------------------------
// Dedicated per-feature permissions. view = day-to-day read, manage = configure.
// ---------------------------------------------------------------------------
// Dedicated perms for admin surfaces that were miscategorised under the generic
// `settings.*` bucket. Their writes were super_admin-only (settings.edit), so
// pointing them here doesn't strip any existing role. Areas that already have a
// sensible domain gate (calendar→customers, deals→customers/bills,
// transfers→events, tax report→bills, short URLs→events, css templates→branding)
// are intentionally left on those gates for now — finer carving there ships with
// the phase-2 photographer role (needs forward-projection to stay compat-safe).
const FEATURE_PERMISSIONS = [
{ name: 'whatsapp.view', display_name: 'View WhatsApp', category: 'whatsapp', description: 'View WhatsApp messaging status and config.' },
{ name: 'whatsapp.manage', display_name: 'Manage WhatsApp', category: 'whatsapp', description: 'Configure and send via WhatsApp.' },
{ name: 'roles.manage', display_name: 'Manage Roles', category: 'users', description: 'Create, edit and delete roles and their permission sets. Highly privileged — a holder can grant any capability, so keep it to owners.' },
{ name: 'vat_codes.view', display_name: 'View VAT Codes', category: 'accounting', description: 'Read the VAT-code list (used by document editors).' },
{ name: 'event_types.view', display_name: 'View Event Types', category: 'events', description: 'Read the event-type list (used when creating events).' },
{ name: 'event_types.manage', display_name: 'Manage Event Types', category: 'events', description: 'Create, edit and delete event types.' },
{ name: 'image_security.view', display_name: 'View Image Security', category: 'photos', description: 'View image-protection / watermarking settings and access logs.' },
{ name: 'image_security.manage',display_name: 'Manage Image Security', category: 'photos', description: 'Configure image protection, block IPs and clear access logs.' },
{ name: 'notifications.view', display_name: 'View Notifications', category: 'system', description: 'View admin notifications.' },
{ name: 'notifications.manage', display_name: 'Manage Notifications', category: 'system', description: 'Mark read and clear admin notifications.' },
{ name: 'system.view', display_name: 'View System', category: 'system', description: 'View system status, version, updates and health.' },
{ name: 'system.manage', display_name: 'Manage System', category: 'system', description: 'Configure update notifications and run system maintenance actions.' },
];
const ALL_NEW_PERMISSIONS = [...SETTINGS_SPLIT_PERMISSIONS, ...FEATURE_PERMISSIONS];
// Preset roles shipped by the app. `permissions: 'ALL'` = every current perm.
// Both are frozen system roles: future-release perms are NOT auto-added (only
// super_admin tracks all); the owner tweaks them via the role editor.
const SOLO_PHOTOGRAPHER = {
name: 'solo_photographer',
display_name: 'Solo Photographer',
description: 'Full operator for a one-person studio — everything needed to run the business (galleries, uploads, CRM, invoices, banking, settings and team management). A preset starting point; new-release permissions are not auto-added (only Super Admin tracks all).',
is_system: true,
priority: 90, // between super_admin (100) and admin (80)
permissions: 'ALL',
};
// Team Photographer — a second/festival shooter who contributes photos but is
// NOT the customer contact: view events + upload/edit/download photos, plus
// read-only CRM context (customers/quotes/invoices). No settings, users,
// billing edits, or events.edit (so no transfers/projects/guests either). The
// "only their assigned events" scoping is phase 2 (#743 event assignment).
const TEAM_PHOTOGRAPHER = {
name: 'team_photographer',
display_name: 'Team Photographer',
description: 'Contributing photographer (second/festival shooter) — view events, upload and manage photos, and see read-only client context. Not the customer contact: no settings, user management, billing edits or event configuration. A preset starting point.',
is_system: true,
priority: 40, // between editor (50) and viewer (20)
permissions: [
'events.view',
'photos.view', 'photos.upload', 'photos.edit', 'photos.download',
'customers.view', 'quotes.view', 'bills.view',
],
};
const PRESET_ROLES = [SOLO_PHOTOGRAPHER, TEAM_PHOTOGRAPHER];
exports.up = async function (knex) {
const hasPermissions = await knex.schema.hasTable('permissions');
const hasRolePermissions = await knex.schema.hasTable('role_permissions');
const hasRoles = await knex.schema.hasTable('roles');
if (!hasPermissions || !hasRolePermissions || !hasRoles) {
console.log('175: RBAC tables missing, skipping permission seed');
return;
}
// 1. Insert all new permissions (skip any already present).
{
const existing = await knex('permissions')
.whereIn('name', ALL_NEW_PERMISSIONS.map((p) => p.name))
.select('name');
const existingSet = new Set(existing.map((r) => r.name));
const toInsert = ALL_NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name));
if (toInsert.length > 0) {
await knex('permissions').insert(toInsert);
console.log(`175: inserted ${toInsert.length} new permissions`);
}
}
// Helper: insert (role_id, permission_id) grants without duplicates.
const grantPerms = async (roleId, permIds) => {
if (!roleId || permIds.length === 0) return;
const existing = await knex('role_permissions')
.where({ role_id: roleId })
.whereIn('permission_id', permIds)
.select('permission_id');
const have = new Set(existing.map((r) => r.permission_id));
const inserts = permIds
.filter((id) => !have.has(id))
.map((id) => ({ role_id: roleId, permission_id: id }));
if (inserts.length > 0) {
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
await knex('role_permissions').insert(inserts.slice(i, i + batchSize));
}
}
};
// 2. Project every perm that REPLACED a settings.edit gate forward: every role
// holding settings.edit today gets each of them, so nobody loses capability
// on upgrade (compat). This covers both the settings.* split AND the feature
// .manage perms that took over settings.edit WRITE gates (whatsapp,
// event_types, image_security, notifications, system) — projecting both
// lists keeps the pattern symmetric for future phase-2 settings.edit holders.
{
const SETTINGS_EDIT_REPLACEMENTS = [
...SETTINGS_SPLIT_PERMISSIONS.map((p) => p.name),
'whatsapp.manage', 'event_types.manage', 'image_security.manage',
'notifications.manage', 'system.manage',
];
const editPerm = await knex('permissions').where({ name: 'settings.edit' }).first();
const replacementPerms = await knex('permissions')
.whereIn('name', SETTINGS_EDIT_REPLACEMENTS)
.select('id');
const replacementIds = replacementPerms.map((p) => p.id);
if (editPerm && replacementIds.length > 0) {
const rolesWithEdit = await knex('role_permissions')
.where({ permission_id: editPerm.id })
.select('role_id');
for (const { role_id } of rolesWithEdit) {
await grantPerms(role_id, replacementIds);
}
}
}
// 3. Grant EVERY new permission to super_admin (the tracks-all owner).
{
const superAdmin = await knex('roles').where({ name: 'super_admin' }).first();
if (superAdmin) {
const newPerms = await knex('permissions')
.whereIn('name', ALL_NEW_PERMISSIONS.map((p) => p.name))
.select('id');
await grantPerms(superAdmin.id, newPerms.map((p) => p.id));
}
}
// 4. Seed the preset roles (Solo + Team Photographer). Each is created with
// its grant set ONLY when missing; an existing preset is left untouched
// (frozen — the owner may have customised it).
for (const preset of PRESET_ROLES) {
const existing = await knex('roles').where({ name: preset.name }).first();
if (existing) continue;
await knex('roles').insert({
name: preset.name,
display_name: preset.display_name,
description: preset.description,
is_system: preset.is_system,
priority: preset.priority,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const role = await knex('roles').where({ name: preset.name }).first();
let permIds;
if (preset.permissions === 'ALL') {
permIds = (await knex('permissions').select('id')).map((p) => p.id);
} else {
const rows = await knex('permissions').whereIn('name', preset.permissions).select('id');
permIds = rows.map((p) => p.id);
}
await grantPerms(role.id, permIds);
console.log(`175: seeded ${preset.name} preset (${permIds.length} permissions)`);
}
console.log('175: granular permissions + presets migration complete');
};
exports.down = async function (knex) {
const hasPermissions = await knex.schema.hasTable('permissions');
if (!hasPermissions) return;
const hasRolePermissions = await knex.schema.hasTable('role_permissions');
const names = ALL_NEW_PERMISSIONS.map((p) => p.name);
const perms = await knex('permissions').whereIn('name', names).select('id');
const ids = perms.map((p) => p.id);
if (ids.length > 0) {
if (hasRolePermissions) await knex('role_permissions').whereIn('permission_id', ids).del();
await knex('permissions').whereIn('id', ids).del();
}
// Remove the preset roles and their grants (each table guarded — the earlier
// hasTable('permissions') check does not imply roles/admin_users exist).
const hasRoles = await knex.schema.hasTable('roles');
if (hasRoles) {
const hasAdminUsers = await knex.schema.hasTable('admin_users');
for (const preset of PRESET_ROLES) {
const role = await knex('roles').where({ name: preset.name }).first();
if (role) {
if (hasRolePermissions) await knex('role_permissions').where({ role_id: role.id }).del();
if (hasAdminUsers) await knex('admin_users').where({ role_id: role.id }).update({ role_id: null });
await knex('roles').where({ id: role.id }).del();
}
}
}
};
module.exports.ALL_NEW_PERMISSIONS = ALL_NEW_PERMISSIONS;
module.exports.SETTINGS_SPLIT_PERMISSIONS = SETTINGS_SPLIT_PERMISSIONS;
module.exports.FEATURE_PERMISSIONS = FEATURE_PERMISSIONS;
module.exports.SOLO_PHOTOGRAPHER = SOLO_PHOTOGRAPHER;
module.exports.TEAM_PHOTOGRAPHER = TEAM_PHOTOGRAPHER;
module.exports.PRESET_ROLES = PRESET_ROLES;
+12
View File
@@ -773,6 +773,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/roles', require('./src/routes/adminRoles'));
// Customer portal (#354). The customerPortal feature flag is a
// VISIBILITY toggle for the admin surface, not a kill switch for
// customer access. Enforcement:
@@ -1048,6 +1049,17 @@ async function startServer() {
logger.warn('built-in workflow seed failed at boot:', err.message);
}
// Self-heal the RBAC catalog: ensure super_admin holds every permission
// (the "Admin tracks all" guarantee) and the solo_photographer preset
// exists. New perms never need a compensation migration. See
// _permissionsBoot.js + project_permission_gating.
try {
const { seedPermissionsAtBoot } = require('./src/services/_permissionsBoot');
await seedPermissionsAtBoot(db, logger);
} catch (err) {
logger.warn('permissions self-heal failed at boot:', err.message);
}
// Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or
// `.txt`) exists in the /backup mount AND the DB is empty, run
// the restore HERE before any admin UI surfaces. Lets admins
+3 -3
View File
@@ -18,7 +18,7 @@ const router = express.Router();
// List tokens for the current admin (or all, if super_admin) — without
// the plaintext, never recoverable after creation.
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/', adminAuth, requirePermission(['settings.view', 'settings.integrations']), async (req, res) => {
try {
// Scope to the caller's own tokens unless super_admin — the previous
// query returned every admin's token metadata (name/preview/scopes/
@@ -60,7 +60,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
router.post(
'/',
adminAuth,
requirePermission('settings.edit'),
requirePermission('settings.integrations'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('scopes').isArray({ min: 1 }).custom((arr) => {
@@ -112,7 +112,7 @@ router.post(
);
// Revoke a token (soft-delete; lookups still find it but reject).
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('settings.integrations'), async (req, res) => {
try {
const { id } = req.params;
const row = await db('api_tokens').where({ id }).first();
+9 -9
View File
@@ -216,7 +216,7 @@ router.use(adminAuth);
// ---- GET / ------------------------------------------------------------
router.get(
'/',
requirePermission('settings.view'),
requirePermission(['settings.view', 'settings.banking']),
handleAsync(async (req, res) => {
const { profile, bankAccounts } = await businessProfileService.getProfile();
return successResponse(res, {
@@ -235,7 +235,7 @@ router.get(
// an existing file. Read-only — never modifies anything.
router.get(
'/logo-diagnostic',
requirePermission('settings.view'),
requirePermission(['settings.view', 'settings.banking']),
handleAsync(async (req, res) => {
const fs = require('fs');
const path = require('path');
@@ -345,7 +345,7 @@ router.get(
// branding_logo_path still applies when this is unset.
router.post(
'/logo',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
pdfLogoUpload.single('logo'),
handleAsync(async (req, res) => {
if (!req.file) {
@@ -380,7 +380,7 @@ router.post(
router.delete(
'/logo',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
handleAsync(async (req, res) => {
const existing = await db('business_profile').where({ id: 1 }).first();
const prev = existing?.logo_path;
@@ -402,7 +402,7 @@ router.delete(
// ---- PUT / ------------------------------------------------------------
router.put(
'/',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
[
// All fields optional — partial update is fine. We only run shallow
// shape validation on the types that absolutely must be sane;
@@ -525,7 +525,7 @@ router.put(
// ---- bank accounts ----------------------------------------------------
router.get(
'/bank-accounts',
requirePermission('settings.view'),
requirePermission(['settings.view', 'settings.banking']),
handleAsync(async (req, res) => {
const { bankAccounts } = await businessProfileService.getProfile();
return successResponse(res, { bankAccounts: bankAccounts.map(transformBank) });
@@ -534,7 +534,7 @@ router.get(
router.post(
'/bank-accounts',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
[
body('iban').isString().isLength({ min: 5, max: 64 }).withMessage('IBAN is required')
.bail().custom(ibanValidator({ required: true })),
@@ -562,7 +562,7 @@ router.post(
router.put(
'/bank-accounts/:id',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
[
param('id').isInt({ min: 1 }),
body('iban').optional({ values: 'falsy' }).isString().isLength({ min: 5, max: 64 })
@@ -599,7 +599,7 @@ router.put(
router.delete(
'/bank-accounts/:id',
requirePermission('settings.edit'),
requirePermission('settings.banking'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
+6 -6
View File
@@ -19,7 +19,7 @@ const router = express.Router();
* GET /admin/event-types
* Get all event types (for admin management)
*/
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/', adminAuth, requirePermission(['settings.view', 'event_types.view', 'events.create', 'events.edit']), async (req, res) => {
try {
const includeInactive = req.query.includeInactive === 'true';
const eventTypes = await eventTypeService.getAllEventTypes({
@@ -51,7 +51,7 @@ router.get('/active', adminAuth, async (req, res) => {
* GET /admin/event-types/:id
* Get a single event type by ID
*/
router.get('/:id', adminAuth, requirePermission('settings.view'), [
router.get('/:id', adminAuth, requirePermission(['settings.view', 'event_types.view', 'events.create', 'events.edit']), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
@@ -78,7 +78,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), [
* POST /admin/event-types
* Create a new event type
*/
router.post('/', adminAuth, requirePermission('settings.edit'), [
router.post('/', adminAuth, requirePermission('event_types.manage'), [
body('name').notEmpty().trim().withMessage('Name is required'),
body('slug_prefix')
.notEmpty()
@@ -138,7 +138,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
* PUT /admin/event-types/:id
* Update an event type
*/
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
router.put('/:id', adminAuth, requirePermission('event_types.manage'), [
param('id').isInt().withMessage('Invalid event type ID'),
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
body('slug_prefix')
@@ -190,7 +190,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
* DELETE /admin/event-types/:id
* Delete an event type (only non-system types with no events)
*/
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
router.delete('/:id', adminAuth, requirePermission('event_types.manage'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
@@ -228,7 +228,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
* POST /admin/event-types/reorder
* Reorder event types by providing an array of IDs in the desired order
*/
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
router.post('/reorder', adminAuth, requirePermission('event_types.manage'), [
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
], async (req, res) => {
+2 -2
View File
@@ -189,7 +189,7 @@ function applyDependencyRules(flags) {
return out;
}
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/', adminAuth, requirePermission(['settings.view', 'settings.features']), async (req, res) => {
try {
const flags = await readAllFlags();
// Always run the rules so derived flags (e.g. `clients`) and
@@ -202,7 +202,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
}
});
router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/', adminAuth, requirePermission('settings.features'), async (req, res) => {
try {
const body = req.body || {};
if (typeof body !== 'object' || Array.isArray(body)) {
+8 -8
View File
@@ -10,7 +10,7 @@ const router = express.Router();
/**
* Get image security settings
*/
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/settings', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
@@ -47,7 +47,7 @@ router.get('/settings', adminAuth, requirePermission('settings.view'), async (re
/**
* Update image security settings
*/
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/settings', adminAuth, requirePermission('image_security.manage'), async (req, res) => {
try {
const updates = req.body;
@@ -98,7 +98,7 @@ router.put('/settings', adminAuth, requirePermission('settings.edit'), async (re
/**
* Get security monitoring dashboard data
*/
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/dashboard', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => {
try {
const { timeframe = '24h' } = req.query;
@@ -203,7 +203,7 @@ router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (r
/**
* Get detailed security logs
*/
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/logs', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => {
try {
const {
page = 1,
@@ -272,7 +272,7 @@ router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, r
/**
* Get image access logs for a specific event
*/
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/events/:eventId/access-logs', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => {
try {
const { eventId } = req.params;
const { page = 1, limit = 50 } = req.query;
@@ -322,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, requirePermission('setting
/**
* Block/unblock suspicious IPs
*/
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.post('/block-ip', adminAuth, requirePermission('image_security.manage'), async (req, res) => {
try {
const { ip, action = 'block' } = req.body;
@@ -368,7 +368,7 @@ router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (r
/**
* Clear security logs older than specified time
*/
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.delete('/logs/cleanup', adminAuth, requirePermission('image_security.manage'), async (req, res) => {
try {
const { olderThan = '30d' } = req.body;
@@ -425,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), as
/**
* Export security data for analysis
*/
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/export', adminAuth, requirePermission(['settings.view', 'image_security.view']), async (req, res) => {
try {
const { format = 'json', timeframe = '7d' } = req.query;
+4 -4
View File
@@ -6,7 +6,7 @@ const logger = require('../utils/logger');
const router = express.Router();
// Get notifications (unread activity logs)
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/', adminAuth, requirePermission(['settings.view', 'notifications.view']), async (req, res) => {
try {
const { limit = 20, includeRead = false } = req.query;
@@ -66,7 +66,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
});
// Mark notification as read
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/:id/read', adminAuth, requirePermission('notifications.manage'), async (req, res) => {
try {
const { id } = req.params;
@@ -84,7 +84,7 @@ router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (re
});
// Mark all notifications as read
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/read-all', adminAuth, requirePermission('notifications.manage'), async (req, res) => {
try {
await db('activity_logs')
.whereNull('read_at')
@@ -108,7 +108,7 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re
// nothing matched the date filter, so it was effectively a confusingly
// named Clear All anyway. Drop the rename and the branching, return
// the simple deletedCount the existing test (and frontend toast) expect.
router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.delete('/clear-all', adminAuth, requirePermission('notifications.manage'), async (req, res) => {
try {
const deletedCount = await db('activity_logs').delete();
res.json({ message: 'All notifications cleared', deletedCount });
+125
View File
@@ -0,0 +1,125 @@
/**
* Role editor API — create/edit/delete roles and their permission sets, and
* read the permission catalog for the matrix UI.
*
* Mounted at /api/admin/roles. Every mutation is gated by the highly-privileged
* `roles.manage` permission (held by super_admin + the solo_photographer preset,
* and grantable to a custom role by the owner). Reads allow `users.view` too so
* the User Management page can show role details without role-editing rights.
*
* System roles (super_admin/admin/editor/viewer/solo_photographer/
* team_photographer) are protected in the service layer: they can't be deleted
* or renamed, and super_admin's permission set is immutable (the boot self-heal
* keeps it complete regardless).
*
* See project_permission_gating.
*/
const express = require('express');
const { body, param } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const userManagementService = require('../services/userManagementService');
const router = express.Router();
const READ_ROLES = ['users.view', 'roles.manage'];
function transformRole(role) {
return {
id: role.id,
name: role.name,
displayName: role.display_name,
description: role.description,
isSystem: role.is_system === true || role.is_system === 1,
priority: role.priority,
userCount: role.user_count,
permissions: role.permissions || [],
};
}
// GET / — list roles with their permissions + user counts
router.get('/', adminAuth, requirePermission(READ_ROLES), handleAsync(async (req, res) => {
const roles = await userManagementService.getRolesWithPermissions();
return successResponse(res, { roles: roles.map(transformRole) });
}));
// GET /permissions — the full permission catalog (for the matrix)
router.get('/permissions', adminAuth, requirePermission(READ_ROLES), handleAsync(async (req, res) => {
const permissions = await userManagementService.getPermissionCatalog();
return successResponse(res, { permissions });
}));
// POST / — create a custom role
router.post('/', [
adminAuth,
requirePermission('roles.manage'),
body('name').isString().trim().isLength({ min: 2, max: 49 }),
body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 500 }),
body('priority').optional().isInt({ min: 0, max: 99 }),
body('permissions').optional().isArray(),
body('permissions.*').optional().isString(),
], handleAsync(async (req, res) => {
validateRequest(req);
const role = await userManagementService.createRole({
name: req.body.name,
displayName: req.body.displayName,
description: req.body.description,
priority: req.body.priority,
permissions: req.body.permissions || [],
}, req.admin.id);
return successResponse(res, { role: transformRole(role) }, 201);
}));
// POST /:id/clone — clone a role (e.g. start from a preset)
router.post('/:id/clone', [
adminAuth,
requirePermission('roles.manage'),
param('id').isInt(),
body('name').isString().trim().isLength({ min: 2, max: 49 }),
body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 500 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const role = await userManagementService.cloneRole(Number(req.params.id), {
name: req.body.name,
displayName: req.body.displayName,
description: req.body.description,
}, req.admin.id);
return successResponse(res, { role: transformRole(role) }, 201);
}));
// PUT /:id — update display/description/priority and/or permission set
router.put('/:id', [
adminAuth,
requirePermission('roles.manage'),
param('id').isInt(),
body('displayName').optional().isString().trim().isLength({ min: 1, max: 150 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 500 }),
body('priority').optional().isInt({ min: 0, max: 99 }),
body('permissions').optional().isArray(),
body('permissions.*').optional().isString(),
], handleAsync(async (req, res) => {
validateRequest(req);
const role = await userManagementService.updateRole(Number(req.params.id), {
displayName: req.body.displayName,
description: req.body.description,
priority: req.body.priority,
permissions: req.body.permissions,
}, req.admin.id);
return successResponse(res, { role: transformRole(role) });
}));
// DELETE /:id — delete a custom role
router.delete('/:id', [
adminAuth,
requirePermission('roles.manage'),
param('id').isInt(),
], handleAsync(async (req, res) => {
validateRequest(req);
await userManagementService.deleteRole(Number(req.params.id), req.admin.id);
return successResponse(res, { success: true });
}));
module.exports = router;
+76 -7
View File
@@ -6,7 +6,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService');
const {
@@ -65,6 +65,63 @@ const stripReservedSettingKeys = (settings) => {
return settings;
};
// Migration 174 hardening — per-key permission boundary for the GENERIC settings
// writers. /general, /analytics, /seo and /security all upsert arbitrary
// setting_keys, so without this a role holding only the broad `settings.edit`
// (or `settings.security`) could set keys owned by a NARROWER permission —
// repointing the public site URL, security policy, or VAT/accounting config —
// via the wrong endpoint, defeating the settings.edit split. Any protected key
// the caller isn't permitted to write is stripped before the upsert. The
// dedicated routes still work because their caller holds the matching perm
// (e.g. PUT /accounting is gated by settings.banking, so accounting_* survives).
const PROTECTED_SETTING_KEY_PERMS = [
{ match: (k) => k === 'general_site_url', perm: 'settings.domains' },
{ match: (k) => k.startsWith('security_'), perm: 'settings.security' },
{ match: (k) => k.startsWith('accounting_'), perm: 'settings.banking' },
];
// Returns the list of {key, perm} the caller tried to CHANGE without the owning
// permission. Callers 403 when it's non-empty rather than silently no-op'ing a
// permission boundary. Change-detection matters: the General tab re-posts
// general_site_url on every save, so a no-op round-trip of the stored value must
// not 403 an otherwise-safe settings.edit save (the office-manager role this PR
// exists to enable) — only an actual change is rejected. A denied key the caller
// couldn't change is left in `settings` (the request 403s before the upsert); an
// unauthorized no-op is dropped so the rest of the save proceeds.
const collectUnauthorizedProtectedKeys = async (settings, adminId) => {
const denied = [];
for (const key of Object.keys(settings)) {
const rule = PROTECTED_SETTING_KEY_PERMS.find((r) => r.match(key));
if (!rule) continue;
if (await userHasAnyPermission(adminId, [rule.perm])) continue;
const row = await db('app_settings').where({ setting_key: key }).first();
let stored = null;
if (row) {
try { stored = JSON.parse(row.setting_value); } catch (_) { stored = row.setting_value; }
}
if (String(stored ?? '') === String(settings[key] ?? '')) {
delete settings[key]; // unchanged — let the rest of the save through
continue;
}
denied.push({ key, perm: rule.perm });
}
return denied;
};
// Express helper: 403 (naming the keys + required perms) when the caller tried
// to write a protected key they don't hold; returns true if the request was
// rejected so the route can stop.
const rejectUnauthorizedProtectedKeys = async (settings, req, res) => {
const denied = await collectUnauthorizedProtectedKeys(settings, req.admin.id);
if (denied.length > 0) {
res.status(403).json({
error: `You don't have permission to change: ${denied.map((d) => d.key).join(', ')}`,
code: 'FORBIDDEN',
keys: denied,
});
return true;
}
return false;
};
// Configure multer for logo uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
@@ -288,7 +345,8 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
// Accounting settings (km rate, per-diem rate, require-proof). Read via the
// generic GET /:type ('accounting'); this is the typed write. Rates are
// integer minor units; verify legal/tax guidance with a Treuhaender.
router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (req, res) => {
// Migration 174: VAT/accounting config is money-adjacent → settings.banking.
router.put('/accounting', adminAuth, requirePermission('settings.banking'), async (req, res) => {
try {
const updates = [];
const setInt = (key) => {
@@ -577,7 +635,8 @@ router.put('/downloads', adminAuth, requirePermission('settings.edit'), async (r
// Read the SSO config. The secret is redacted to a set/unset flag; the
// computed redirect URI is included for copy-paste into the IdP client.
router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => {
// Migration 174: SSO/OIDC + security config → settings.security.
router.get('/sso', adminAuth, requirePermission(['settings.view', 'settings.security']), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
@@ -610,7 +669,7 @@ router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, re
}
});
router.put('/sso', adminAuth, requirePermission('settings.edit'), [
router.put('/sso', adminAuth, requirePermission('settings.security'), [
body('oidc_enabled').optional().isBoolean(),
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
body('oidc_client_id').optional().isString().trim(),
@@ -717,7 +776,7 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [
// Server-side discovery probe: confirms the issuer is reachable and speaks
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.post('/sso/test', adminAuth, requirePermission('settings.security'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
@@ -1300,6 +1359,12 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
const settings = stripReservedSettingKeys({ ...req.body });
let uploadLimitTouched = false;
// Migration 174: drop any protected key (site URL / security / accounting)
// the caller isn't permitted to write, so the settings.edit bucket can't be
// used to repoint the install via this generic writer. See
// rejectUnauthorizedProtectedKeys (403s when a protected key is denied).
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
@@ -1425,9 +1490,11 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
});
// Update security settings
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/security', adminAuth, requirePermission('settings.security'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
// A settings.security holder still can't write domain/accounting keys here.
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
@@ -1466,6 +1533,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
// Validate the provider switch (#663 Phase 1). Reject unknown values
// so the dashboard route's factory doesn't have to defensively guard.
@@ -1521,6 +1589,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = stripReservedSettingKeys({ ...req.body });
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
@@ -1845,7 +1914,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
});
// Update rate limit settings
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
router.put('/security/rate-limit', adminAuth, requirePermission('settings.security'), [
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
+12 -12
View File
@@ -20,7 +20,7 @@ const {
const router = express.Router();
// Get system version
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/version', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
// Read backend version from package.json
let backendVersion = '1.0.0';
@@ -49,7 +49,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
});
// Check for updates
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/updates', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
@@ -92,7 +92,7 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
// brand-new install initialises the marker silently so it never pops
// "what's new" with nothing to compare against. Best-effort: any failure
// (GitHub unreachable, etc.) returns hasNews:false, never errors.
router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/updates/whatsnew', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
if (process.env.UPDATE_CHECK_ENABLED === 'false') {
return res.json({ enabled: false, hasNews: false });
@@ -137,7 +137,7 @@ router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), a
// Acknowledge the What's New — advance the per-instance marker to the
// running version so it stops showing for every admin.
router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.post('/updates/whatsnew/seen', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
const running = await getCurrentVersion();
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
@@ -153,7 +153,7 @@ router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.vie
// admin can read release notes for ALL versions they're behind on, not
// just the latest. Body is raw GitHub-flavoured markdown; rendering is
// the client's job (frontend uses `marked`).
router.get('/updates/changelog', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/updates/changelog', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
@@ -176,7 +176,7 @@ router.get('/updates/changelog', adminAuth, requirePermission('settings.view'),
});
// Get update instructions for current environment
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/updates/instructions', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
@@ -217,7 +217,7 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view'
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/status', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
// Database size. Read the LIVE connection rather than re-deriving any of
// this from the environment (#1038): DATABASE_CLIENT is not the only thing
@@ -351,7 +351,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
});
// Get database statistics
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/database', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
// Get table info
const tables = [
@@ -413,7 +413,7 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
});
// Get update notification settings
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/updates/notifications', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
const settings = await getUpdateNotificationSettings();
res.json(settings);
@@ -424,7 +424,7 @@ router.get('/updates/notifications', adminAuth, requirePermission('settings.view
});
// Update notification settings
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/updates/notifications', adminAuth, requirePermission('system.manage'), async (req, res) => {
try {
const { enabled, recipients } = req.body;
@@ -460,7 +460,7 @@ router.put('/updates/notifications', adminAuth, requirePermission('settings.edit
// `version_update_available`, so admins on the latest version can still
// verify their SMTP + recipient config — the previous handler bailed
// with "No updates available" when nothing was pending (#418).
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.post('/updates/notifications/send', adminAuth, requirePermission('system.manage'), async (req, res) => {
try {
const result = await sendTestUpdateNotification();
res.json(result);
@@ -471,7 +471,7 @@ router.post('/updates/notifications/send', adminAuth, requirePermission('setting
});
// Check and send update notifications (called on admin login or periodically)
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.post('/updates/notifications/check', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
try {
const result = await checkAndNotifyUpdates();
res.json(result);
+5 -5
View File
@@ -33,7 +33,7 @@ const VALID_SCOPES = ['quote', 'contract', 'contract-signature', 'invoice'];
router.get(
'/backup-integrity',
requirePermission('settings.view'),
requirePermission(['settings.view', 'system.view']),
[
// CSV string like `?scope=contract,invoice`. Each member must be
// one of the four known scopes. Empty / omitted means full scan.
@@ -78,7 +78,7 @@ router.get(
*/
router.get(
'/backup-coverage',
requirePermission('settings.view'),
requirePermission(['settings.view', 'system.view']),
handleAsync(async (req, res) => {
const report = await getCoverageReport();
return successResponse(res, { report });
@@ -97,7 +97,7 @@ router.get(
*/
router.get(
'/failures',
requirePermission('settings.view'),
requirePermission(['settings.view', 'system.view']),
handleAsync(async (req, res) => {
const stuckEmails = await db('email_queue')
.where(function () {
@@ -132,7 +132,7 @@ router.get(
*/
router.post(
'/failures/email/:id/retry',
requirePermission('settings.edit'),
requirePermission('system.manage'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
@@ -153,7 +153,7 @@ router.post(
*/
router.delete(
'/failures/email/:id',
requirePermission('settings.edit'),
requirePermission('system.manage'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
+14 -1
View File
@@ -11,12 +11,25 @@
*/
const express = require('express');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, successResponse } = require('../utils/routeHelpers');
const ledgerService = require('../services/ledgerService');
const router = express.Router();
router.get('/', adminAuth, handleAsync(async (req, res) => {
// Migration 174: the VAT-code list is reference data the invoice/quote editors
// read, so it must stay readable by anyone who can touch those documents. We
// gate it with a broad OR (its own vat_codes.view plus every CRM-editor perm)
// rather than a single narrow perm — completeness without breaking any editor.
const VAT_CODE_READERS = [
'vat_codes.view',
'bills.view', 'bills.manage',
'quotes.view', 'quotes.manage',
'contracts.view', 'contracts.manage',
'accounting.view', 'accounting.manage',
];
router.get('/', adminAuth, requirePermission(VAT_CODE_READERS), handleAsync(async (req, res) => {
const items = await ledgerService.listVatCodes();
const active = items.filter((v) => v.active !== false);
const { direction } = req.query;
+13 -9
View File
@@ -20,6 +20,10 @@ 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');
@@ -52,7 +56,7 @@ function safeJson(s, fallback) {
}
// ─── List ────────────────────────────────────────────────────────────────
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
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')
@@ -75,7 +79,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
router.post(
'/',
adminAuth,
requirePermission('settings.edit'),
requirePermission('settings.integrations'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
@@ -142,7 +146,7 @@ router.post(
);
// ─── Detail ──────────────────────────────────────────────────────────────
router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, res) => {
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' });
@@ -157,7 +161,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, re
router.put(
'/:id',
adminAuth,
requirePermission('settings.edit'),
requirePermission('settings.integrations'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
@@ -217,7 +221,7 @@ router.put(
);
// ─── Delete ──────────────────────────────────────────────────────────────
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
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' });
@@ -236,7 +240,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
router.post(
'/:id/test',
adminAuth,
requirePermission('settings.edit'),
requirePermission('settings.integrations'),
[body('event_type').optional().isIn(webhookService.EVENT_TYPES)],
async (req, res) => {
try {
@@ -284,7 +288,7 @@ router.post(
router.get(
'/:id/deliveries',
adminAuth,
requirePermission('settings.view'),
requirePermission(['settings.view', 'settings.integrations']),
[
query('status').optional().isIn(['pending', 'success', 'failed']),
query('page').optional().isInt({ min: 1 }),
@@ -330,7 +334,7 @@ router.get(
router.get(
'/:id/deliveries/:deliveryId',
adminAuth,
requirePermission('settings.view'),
requirePermission(['settings.view', 'settings.integrations']),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
@@ -352,7 +356,7 @@ router.get(
router.post(
'/:id/deliveries/:deliveryId/replay',
adminAuth,
requirePermission('settings.edit'),
requirePermission('settings.integrations'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
+3 -3
View File
@@ -33,7 +33,7 @@ const logger = require('../utils/logger');
// pattern). The Settings UI hides the tab as well; this is defence in depth.
router.use(requireFeatureFlag('whatsapp'));
router.get('/config', adminAuth, requirePermission('settings.view'), async (req, res) => {
router.get('/config', adminAuth, requirePermission(['settings.view', 'whatsapp.view']), async (req, res) => {
try {
const config = await db('whatsapp_configs').first();
if (!config) {
@@ -62,7 +62,7 @@ router.get('/config', adminAuth, requirePermission('settings.view'), async (req,
}
});
router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.put('/config', adminAuth, requirePermission('whatsapp.manage'), async (req, res) => {
try {
const { phone_number_id, waba_id, access_token, template_name, template_language, template_params, enabled } = req.body;
@@ -142,7 +142,7 @@ router.put('/config', adminAuth, requirePermission('settings.edit'), async (req,
}
});
router.post('/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
router.post('/test', adminAuth, requirePermission('whatsapp.manage'), async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
+148
View File
@@ -0,0 +1,148 @@
/**
* Boot-time self-heal for the RBAC permission catalog.
*
* The single durable guarantee here: the `super_admin` role holds EVERY
* permission currently defined. This is the "Admin tracks all" mechanism —
* whenever a future release adds a permission (via its migration), this boot
* pass grants it to super_admin automatically, so a new perm never needs a
* compensation migration and the owner is never locked out of a new feature.
* See feedback_self_heal_pattern + project_permission_gating.
*
* Deliberately NARROW: it only ever backfills `super_admin`. All other roles —
* admin, editor, viewer, the solo_photographer preset, and any custom roles an
* org creates — are FROZEN: new perms default OFF for them so nobody silently
* gains a capability (e.g. the ability to change IBAN/domains) on upgrade. The
* owner grants those explicitly via the role editor.
*
* The solo_photographer preset itself is seeded (with all-perms-at-seed-time) by
* migration 174; this pass only ensures it exists so a partially-migrated or
* hand-restored DB still shows the preset. Its grants are never re-synced.
*
* Idempotent and best-effort: any failure is logged and swallowed so a boot is
* never blocked by permission housekeeping.
*/
// Preset roles shipped with the app. `permissions: 'ALL'` = every current perm.
// Kept in sync with migration 174 (PRESET_ROLES). This boot pass only ensures a
// preset EXISTS (create + grant if missing) so a partially-migrated or restored
// DB still shows it; it never re-syncs an existing preset's grants (frozen).
const PRESETS = [
{
name: 'solo_photographer',
display_name: 'Solo Photographer',
description: 'Full operator for a one-person studio — everything needed to run the business. A preset starting point; new-release permissions are not auto-added (only Super Admin tracks all).',
is_system: true,
priority: 90,
permissions: 'ALL',
},
{
name: 'team_photographer',
display_name: 'Team Photographer',
description: 'Contributing photographer (second/festival shooter) — view events, upload and manage photos, and see read-only client context. Not the customer contact: no settings, user management, billing edits or event configuration. A preset starting point.',
is_system: true,
priority: 40,
permissions: [
'events.view',
'photos.view', 'photos.upload', 'photos.edit', 'photos.download',
'customers.view', 'quotes.view', 'bills.view',
],
},
];
async function ensureSuperAdminHasAllPermissions(db, logger) {
const superAdmin = await db('roles').where({ name: 'super_admin' }).first();
if (!superAdmin) return 0;
const allPerms = await db('permissions').select('id');
if (allPerms.length === 0) return 0;
const held = await db('role_permissions')
.where({ role_id: superAdmin.id })
.select('permission_id');
const heldSet = new Set(held.map((r) => r.permission_id));
const inserts = allPerms
.filter((p) => !heldSet.has(p.id))
.map((p) => ({ role_id: superAdmin.id, permission_id: p.id }));
if (inserts.length > 0) {
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
await db('role_permissions').insert(inserts.slice(i, i + batchSize));
}
logger?.info?.(`Permissions self-heal: granted ${inserts.length} missing permission(s) to super_admin`);
}
return inserts.length;
}
async function ensurePreset(db, logger, preset) {
const existing = await db('roles').where({ name: preset.name }).first();
if (existing) return; // frozen — never re-sync its grants
await db('roles').insert({
name: preset.name,
display_name: preset.display_name,
description: preset.description,
is_system: preset.is_system,
priority: preset.priority,
created_at: db.fn.now(),
updated_at: db.fn.now(),
});
const role = await db('roles').where({ name: preset.name }).first();
if (!role) return;
let permIds;
if (preset.permissions === 'ALL') {
permIds = (await db('permissions').select('id')).map((p) => p.id);
} else {
const rows = await db('permissions').whereIn('name', preset.permissions).select('id');
permIds = rows.map((p) => p.id);
}
if (permIds.length > 0) {
const inserts = permIds.map((id) => ({ role_id: role.id, permission_id: id }));
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
await db('role_permissions').insert(inserts.slice(i, i + batchSize));
}
}
logger?.info?.(`Permissions self-heal: seeded missing ${preset.name} preset (${permIds.length} permissions)`);
}
async function seedPermissionsAtBoot(db, logger) {
try {
const hasRoles = await db.schema.hasTable('roles');
const hasPerms = await db.schema.hasTable('permissions');
const hasRolePerms = await db.schema.hasTable('role_permissions');
if (!hasRoles || !hasPerms || !hasRolePerms) return;
// Per-step try/catch: on a multi-replica start the loser of a
// role_permissions insert race can throw a PK violation in one step; that
// must not skip the remaining steps (e.g. preset seeding) on that replica.
let granted = 0;
try {
granted = await ensureSuperAdminHasAllPermissions(db, logger);
} catch (err) {
logger?.warn?.('Permissions self-heal: super_admin backfill failed:', err.message);
}
for (const preset of PRESETS) {
try {
await ensurePreset(db, logger, preset);
} catch (err) {
logger?.warn?.(`Permissions self-heal: preset ${preset.name} failed:`, err.message);
}
}
if (granted > 0) {
// Drop the in-memory permission cache so the new grants take effect
// without waiting out the 60s TTL.
try {
const { clearPermissionCache } = require('../middleware/permissions');
clearPermissionCache?.();
} catch (_) { /* cache module optional at boot */ }
}
} catch (err) {
logger?.warn?.('Permissions self-heal failed at boot:', err.message);
}
}
module.exports = { seedPermissionsAtBoot };
+265 -1
View File
@@ -11,7 +11,7 @@ const { generateReadablePassword } = require('../utils/passwordGenerator');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
const { ConflictError, NotFoundError, ValidationError, ForbiddenError } = require('../utils/errors');
/**
* Create a new admin user invitation
@@ -567,6 +567,263 @@ async function validateInvitationToken(token) {
return invitation || null;
}
// ---------------------------------------------------------------------------
// Role management (the role editor). Highly privileged — every mutation here is
// gated by `roles.manage` at the route layer. See project_permission_gating.
// ---------------------------------------------------------------------------
// System roles that ship with the app. Their `name` (the semantic key routes
// check) is immutable and they cannot be deleted; their display/description and
// (except super_admin) their permission set may be tweaked.
const RESERVED_ROLE_NAMES = ['super_admin', 'admin', 'editor', 'viewer', 'solo_photographer', 'team_photographer'];
function clearPermCache() {
// Bust the RBAC middleware cache so grant changes take effect immediately
// rather than waiting out its 60s TTL. Lazy-required to avoid a load cycle.
try { require('../middleware/permissions').clearPermissionCache(); } catch (_) { /* optional */ }
}
function normalizeRoleName(name) {
return String(name || '').trim().toLowerCase();
}
async function resolvePermissionIds(permissionNames) {
const names = Array.from(new Set((permissionNames || []).filter(Boolean)));
if (names.length === 0) return [];
const rows = await db('permissions').whereIn('name', names).select('id', 'name');
const found = new Set(rows.map((r) => r.name));
const missing = names.filter((n) => !found.has(n));
if (missing.length > 0) {
throw new ValidationError(`Unknown permission(s): ${missing.join(', ')}`);
}
return rows.map((r) => r.id);
}
// Contain the roles.manage blast radius (delegation, not root escalation): a
// non-super_admin managing roles may only grant permissions their OWN role
// already holds, so `roles.manage` can't be turned into "grant myself
// everything". super_admin bypasses (it holds the full catalog anyway).
async function assertActorMayGrant(actorId, permissionNames) {
const names = Array.from(new Set((permissionNames || []).filter(Boolean)));
if (names.length === 0) return;
const actor = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', actorId)
.select('roles.name as role_name')
.first();
if (actor && actor.role_name === 'super_admin') return;
const { userHasAllPermissions } = require('../middleware/permissions');
if (!(await userHasAllPermissions(actorId, names))) {
throw new ForbiddenError('You can only grant permissions your own role already holds.');
}
}
async function getRoleWithPermissionsById(id) {
const role = await db('roles')
.where('id', id)
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.first();
if (!role) return null;
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', id)
.pluck('permissions.name');
return { ...role, permissions: permissions.sort() };
}
/**
* All roles with their permission-name arrays and assigned-user counts. Backs
* the role-editor list.
*/
async function getRolesWithPermissions() {
const roles = await db('roles')
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.orderBy('priority', 'desc');
const grants = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.select('role_permissions.role_id as role_id', 'permissions.name as name');
const userCounts = await db('admin_users')
.whereNotNull('role_id')
.select('role_id')
.count('* as count')
.groupBy('role_id');
const permsByRole = new Map();
for (const g of grants) {
if (!permsByRole.has(g.role_id)) permsByRole.set(g.role_id, []);
permsByRole.get(g.role_id).push(g.name);
}
const countByRole = new Map(userCounts.map((r) => [r.role_id, Number(r.count)]));
return roles.map((r) => ({
...r,
permissions: (permsByRole.get(r.id) || []).sort(),
user_count: countByRole.get(r.id) || 0,
}));
}
/**
* The full permission catalog (for the editor's matrix), ordered by category.
*/
async function getPermissionCatalog() {
return db('permissions')
.select('id', 'name', 'display_name', 'category', 'description')
.orderBy(['category', 'name']);
}
/**
* Create a custom role with an explicit permission set.
*/
async function createRole({ name, displayName, description, priority, permissions }, createdById) {
const normalized = normalizeRoleName(name);
if (!/^[a-z][a-z0-9_]{1,48}$/.test(normalized)) {
throw new ValidationError('Role name must be lowercase letters, numbers and underscores (249 chars, starting with a letter).');
}
if (RESERVED_ROLE_NAMES.includes(normalized)) {
throw new ConflictError(`"${normalized}" is a reserved system role name.`);
}
const existing = await db('roles').where('name', normalized).first();
if (existing) throw new ConflictError(`A role named "${normalized}" already exists.`);
await assertActorMayGrant(createdById, permissions);
const permIds = await resolvePermissionIds(permissions);
const rawPriority = Number(priority);
const safePriority = Number.isFinite(rawPriority) ? Math.max(0, Math.min(99, Math.floor(rawPriority))) : 50;
let roleId;
await db.transaction(async (trx) => {
const ins = await trx('roles').insert({
name: normalized,
display_name: displayName || normalized,
description: description || null,
is_system: false,
priority: safePriority,
created_at: trx.fn.now(),
updated_at: trx.fn.now(),
}).returning('id');
roleId = ins[0]?.id ?? ins[0];
if (permIds.length > 0) {
await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: roleId, permission_id: pid })));
}
});
clearPermCache();
// logActivity AFTER commit — a global-db write inside the trx would deadlock
// on SQLite. See feedback_sqlite_global_write_in_transaction.
await logActivity('admin_role_created',
{ roleId, name: normalized, permissionCount: permIds.length },
null,
{ type: 'admin', id: createdById, name: 'system' });
logger.info('Role created', { roleId, name: normalized, createdById });
return getRoleWithPermissionsById(roleId);
}
/**
* Update a role's display/description/priority and/or replace its permission
* set. super_admin is fully protected; system roles keep their name + priority.
*/
async function updateRole(id, { displayName, description, priority, permissions }, updatedById) {
const role = await db('roles').where('id', id).first();
if (!role) throw new NotFoundError('Role', id);
if (role.name === 'super_admin') {
throw new ValidationError('The Super Admin role is protected — it always holds every permission and cannot be edited.');
}
// Self-amplification guard: a non-super_admin can't edit their OWN role (which
// would let a roles.manage holder grant their own role more), and can only
// grant permissions they already hold. See assertActorMayGrant.
const actor = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', updatedById)
.select('admin_users.role_id as role_id', 'roles.name as role_name')
.first();
const actorIsSuper = actor && actor.role_name === 'super_admin';
if (!actorIsSuper && actor && actor.role_id === Number(id)) {
throw new ForbiddenError('You cannot edit your own role.');
}
if (permissions !== undefined) {
await assertActorMayGrant(updatedById, permissions);
}
const patch = { updated_at: db.fn.now() };
if (displayName !== undefined) patch.display_name = displayName;
if (description !== undefined) patch.description = description;
if (priority !== undefined && !role.is_system) {
const p = Number(priority);
if (Number.isFinite(p)) patch.priority = Math.max(0, Math.min(99, Math.floor(p)));
}
let permIds = null;
if (permissions !== undefined) {
permIds = await resolvePermissionIds(permissions);
}
await db.transaction(async (trx) => {
await trx('roles').where('id', id).update(patch);
if (permIds !== null) {
await trx('role_permissions').where('role_id', id).del();
if (permIds.length > 0) {
await trx('role_permissions').insert(permIds.map((pid) => ({ role_id: id, permission_id: pid })));
}
}
});
clearPermCache();
await logActivity('admin_role_updated',
{ roleId: id, name: role.name, permissionsChanged: permIds !== null },
null,
{ type: 'admin', id: updatedById, name: 'system' });
logger.info('Role updated', { roleId: id, name: role.name, updatedById });
return getRoleWithPermissionsById(id);
}
/**
* Delete a custom role. System roles are protected; a role still assigned to
* users must be reassigned first (avoids leaving users permission-less).
*/
async function deleteRole(id, deletedById) {
const role = await db('roles').where('id', id).first();
if (!role) throw new NotFoundError('Role', id);
if (role.is_system) throw new ValidationError('System roles cannot be deleted.');
const assigned = await db('admin_users').where('role_id', id).count('* as count').first();
if (Number(assigned?.count) > 0) {
throw new ConflictError('Reassign the users holding this role before deleting it.');
}
await db.transaction(async (trx) => {
await trx('role_permissions').where('role_id', id).del();
await trx('roles').where('id', id).del();
});
clearPermCache();
await logActivity('admin_role_deleted',
{ roleId: id, name: role.name },
null,
{ type: 'admin', id: deletedById, name: 'system' });
logger.info('Role deleted', { roleId: id, name: role.name, deletedById });
}
/**
* Clone any role (including a preset) into a new custom role with the same
* permission set — the "start from a preset" flow.
*/
async function cloneRole(sourceId, { name, displayName, description }, createdById) {
const source = await db('roles').where('id', sourceId).first();
if (!source) throw new NotFoundError('Role', sourceId);
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', sourceId)
.pluck('permissions.name');
return createRole({
name,
displayName: displayName || `${source.display_name} copy`,
description: description !== undefined ? description : source.description,
priority: source.is_system ? 50 : source.priority,
permissions,
}, createdById);
}
module.exports = {
createInvitation,
acceptInvitation,
@@ -578,6 +835,13 @@ module.exports = {
deleteAdminUser,
resetAdminPassword,
getAllRoles,
getRolesWithPermissions,
getPermissionCatalog,
getRoleWithPermissionsById,
createRole,
updateRole,
deleteRole,
cloneRole,
getPendingInvitations,
cancelInvitation,
validateInvitationToken