feat(rbac): make Settings authorization permission-aware (#1738)

* feat(rbac): make Settings authorization permission-aware

Align Settings visibility and mutations with the existing permission matrix so Node Admin can edit node-scoped operational settings while system and credential surfaces stay Admin-protected.

* fix(rbac): tighten settings permission buckets and tests

Collapse settings key permission maps into one source of truth, and cover mixed PATCH atomicity plus image-update enabled writes.

* fix(rbac): tighten Settings scoped grants and CI assertions

Empty settings PATCH fails closed, node:manage is scoped to the active
node, system-only Settings stay hidden without system:settings, and
Check updates / webhooks mutate gates follow the permission matrix.

* fix(rbac): defer Settings section fallback until authz is ready

Keep deep links to permission-gated sections (e.g. license) intact while
can() is still fail-closed during permission metadata load.

* docs(settings): clarify Notifications channels vs routing authz

Channels use node:manage via /api/agents; routing and mute stay Admin-only.
This commit is contained in:
Anso
2026-07-30 10:25:13 -04:00
committed by GitHub
parent c704cb54d2
commit a3026f47a8
46 changed files with 812 additions and 180 deletions
@@ -0,0 +1,308 @@
/**
* Settings write authorization: per-key permission buckets on /api/settings
* and Settings-scoped image-update routes.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import type { UserRole } from '../services/DatabaseService';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
const roleCookie: Partial<Record<UserRole, string>> = {};
async function seedAndLogin(role: UserRole): Promise<string> {
const username = `settings-perm-${role}`;
const password = `${username}-pass`;
const passwordHash = await bcrypt.hash(password, 1);
DatabaseService.getInstance().addUser({ username, password_hash: passwordHash, role });
const res = await request(app).post('/api/auth/login').send({ username, password });
const cookies = res.headers['set-cookie'] as string | string[];
return Array.isArray(cookies) ? cookies[0] : cookies;
}
let LicenseService: typeof import('../services/LicenseService').LicenseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
roleCookie[role] = await seedAndLogin(role);
}
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
});
describe('PATCH /api/settings permission buckets', () => {
it('lets node-admin write a node:manage key', async () => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({ host_cpu_limit: 80 });
expect(res.status).toBe(200);
});
it('rejects node-admin writing a system:settings key', async () => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({ developer_mode: '1' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('rejects mixed node-manage + system-settings PATCH from node-admin', async () => {
const before = DatabaseService.getInstance().getGlobalSettings().host_cpu_limit;
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({ host_cpu_limit: 80, developer_mode: '1' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(DatabaseService.getInstance().getGlobalSettings().host_cpu_limit).toBe(before);
});
it.each(['deployer', 'viewer', 'auditor'] as const)(
'rejects %s writing a node:manage key',
async (role) => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie[role]!)
.send({ host_cpu_limit: 70 });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
},
);
it('lets admin write system:settings keys', async () => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', adminCookie)
.send({ developer_mode: '0' });
expect(res.status).toBe(200);
});
it('lets admin empty PATCH as a no-op', async () => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', adminCookie)
.send({});
expect(res.status).toBe(200);
});
it('lets node-admin empty PATCH as a no-op', async () => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({});
expect(res.status).toBe(200);
});
it.each(['deployer', 'viewer', 'auditor'] as const)(
'rejects empty PATCH from %s',
async (role) => {
const res = await request(app)
.patch('/api/settings')
.set('Cookie', roleCookie[role]!)
.send({});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
},
);
it('lets node-admin POST a single node:manage key', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({ key: 'host_cpu_limit', value: 75 });
expect(res.status).toBe(200);
});
it('rejects node-admin POST of a system:settings key', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', roleCookie['node-admin']!)
.send({ key: 'developer_mode', value: '1' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('honors node-scoped node-admin grants for node:manage writes', async () => {
const db = DatabaseService.getInstance();
const defaultNodeId = db.getDefaultNode()!.id!;
const remoteId = db.addNode({
name: 'settings-scoped-remote',
type: 'remote',
api_url: 'http://192.168.1.50:1852',
api_token: 'test-token',
compose_dir: '/tmp',
is_default: false,
});
const allowedPassword = 'settings-scoped-allow-pass';
const allowedUserId = db.addUser({
username: 'settings-scoped-allow',
password_hash: await bcrypt.hash(allowedPassword, 1),
role: 'viewer',
});
db.addRoleAssignment({
user_id: allowedUserId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(defaultNodeId),
});
const allowedLogin = await request(app).post('/api/auth/login').send({
username: 'settings-scoped-allow',
password: allowedPassword,
});
const allowedCookies = allowedLogin.headers['set-cookie'] as string | string[];
const allowedCookie = Array.isArray(allowedCookies) ? allowedCookies[0] : allowedCookies;
const allowed = await request(app)
.patch('/api/settings')
.set('Cookie', allowedCookie)
.set('x-node-id', String(defaultNodeId))
.send({ host_cpu_limit: 81 });
expect(allowed.status).toBe(200);
// Grant only on a remote node; local default writes must still 403 (and stay
// on the local settings route, not the remote proxy).
const deniedPassword = 'settings-scoped-deny-pass';
const deniedUserId = db.addUser({
username: 'settings-scoped-deny',
password_hash: await bcrypt.hash(deniedPassword, 1),
role: 'viewer',
});
db.addRoleAssignment({
user_id: deniedUserId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(remoteId),
});
const deniedLogin = await request(app).post('/api/auth/login').send({
username: 'settings-scoped-deny',
password: deniedPassword,
});
const deniedCookies = deniedLogin.headers['set-cookie'] as string | string[];
const deniedCookie = Array.isArray(deniedCookies) ? deniedCookies[0] : deniedCookies;
const denied = await request(app)
.patch('/api/settings')
.set('Cookie', deniedCookie)
.set('x-node-id', String(defaultNodeId))
.send({ host_cpu_limit: 82 });
expect(denied.status).toBe(403);
expect(denied.body.code).toBe('PERMISSION_DENIED');
});
});
describe('Settings feature routes permission matrix', () => {
it('rejects node-admin on system:* feature mutations', async () => {
const cookie = roleCookie['node-admin']!;
const cases: Array<{ method: 'get' | 'post' | 'put' | 'delete'; path: string; body?: object }> = [
{ method: 'get', path: '/api/users' },
{ method: 'post', path: '/api/api-tokens', body: { name: 'x', scope: 'read-only' } },
{ method: 'post', path: '/api/webhooks', body: { name: 'x', stack_name: 'demo', action: 'restart' } },
{ method: 'post', path: '/api/registries', body: { name: 'x', url: 'https://example.com', username: 'u', password: 'p' } },
{ method: 'post', path: '/api/license/activate', body: { license_key: 'x' } },
];
for (const c of cases) {
const req = request(app)[c.method](c.path).set('Cookie', cookie);
const res = c.body ? await req.send(c.body) : await req;
expect(res.status, c.path).toBe(403);
expect(res.body.code, c.path).toBe('PERMISSION_DENIED');
}
});
it('lets node-admin upsert a notification agent channel', async () => {
const res = await request(app)
.post('/api/agents')
.set('Cookie', roleCookie['node-admin']!)
.send({
type: 'discord',
url: 'https://discord.com/api/webhooks/123/abc',
enabled: true,
});
expect(res.status).toBe(200);
});
it('rejects viewer upserting a notification agent channel', async () => {
const res = await request(app)
.post('/api/agents')
.set('Cookie', roleCookie.viewer!)
.send({
type: 'discord',
url: 'https://discord.com/api/webhooks/123/abc',
enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
describe('image-updates Settings-scoped routes', () => {
it('rejects node-admin PUT /interval (system:settings)', async () => {
const res = await request(app)
.put('/api/image-updates/interval')
.set('Cookie', roleCookie['node-admin']!)
.send({ minutes: 60 });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('rejects node-admin PUT /enabled (system:settings)', async () => {
const res = await request(app)
.put('/api/image-updates/enabled')
.set('Cookie', roleCookie['node-admin']!)
.send({ enabled: false });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('lets admin PUT /interval', async () => {
const res = await request(app)
.put('/api/image-updates/interval')
.set('Cookie', adminCookie)
.send({ minutes: 60 });
expect(res.status).toBe(200);
});
it('lets admin PUT /enabled', async () => {
const res = await request(app)
.put('/api/image-updates/enabled')
.set('Cookie', adminCookie)
.send({ enabled: true });
expect(res.status).toBe(200);
});
it('lets node-admin POST /refresh (node:manage)', async () => {
const res = await request(app)
.post('/api/image-updates/refresh')
.set('Cookie', roleCookie['node-admin']!);
// 200 on success, 409 when checks disabled, 429 on cooldown — not 403.
expect(res.status).not.toBe(403);
expect([200, 409, 429]).toContain(res.status);
});
it('rejects viewer POST /refresh', async () => {
const res = await request(app)
.post('/api/image-updates/refresh')
.set('Cookie', roleCookie.viewer!);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
+2 -2
View File
@@ -118,7 +118,7 @@ describe('POST /api/users', () => {
.set('Authorization', `Bearer ${viewerToken}`)
.send({ username: 'test999', password: 'password123', role: 'viewer' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('blocks API tokens (403 SCOPE_DENIED)', async () => {
@@ -890,6 +890,6 @@ describe('ROLE_PERMISSIONS enforcement via API', () => {
.get('/api/users')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
expect(res.body.code).toBe('PERMISSION_DENIED');
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import {
@@ -27,7 +27,8 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
});
agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const nodeId = req.nodeId ?? 0;
if (!requirePermission(req, res, 'node:manage', 'node', String(nodeId))) return;
try {
const { type, url, enabled, config } = req.body;
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
@@ -38,7 +39,6 @@ agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const nodeId = req.nodeId ?? 0;
const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type);
const effectiveUrl = url === undefined ? existing?.url : url;
+4 -4
View File
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
import crypto from 'crypto';
import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { isDebugEnabled } from '../utils/debug';
import { parseIntParam } from '../utils/parseIntParam';
@@ -16,7 +16,7 @@ export const apiTokensRouter = Router();
apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:tokens')) return;
try {
const { name, scope, expires_in } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
@@ -79,7 +79,7 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P
apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:tokens')) return;
try {
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
@@ -95,7 +95,7 @@ apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Pr
apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:tokens')) return;
try {
const id = parseIntParam(req, res, 'id', 'token ID');
if (id === null) return;
+4 -3
View File
@@ -20,6 +20,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { HealthGateService } from '../services/HealthGateService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { buildPolicyGateOptions } from '../helpers/policyGate';
import { FLEET_UPDATE_CACHE_KEY, invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
@@ -75,7 +76,7 @@ imageUpdatesRouter.get('/detail', authMiddleware, (req: Request, res: Response):
});
imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:manage', 'node', String(req.nodeId ?? 0))) return;
try {
if (!ImageUpdateService.isChecksEnabled()) {
res.status(409).json({
@@ -146,7 +147,7 @@ const IntervalPatchSchema = z.object({
});
imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:settings')) return;
const parsed = IntervalPatchSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'minutes must be an integer between 15 and 1440' });
@@ -193,7 +194,7 @@ const EnabledPatchSchema = z.object({
});
imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:settings')) return;
const parsed = EnabledPatchSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'enabled must be a boolean' });
+6 -5
View File
@@ -1,7 +1,8 @@
import { Router, type Request, type Response } from 'express';
import { LicenseService } from '../services/LicenseService';
import SelfUpdateService from '../services/SelfUpdateService';
import { requireAdmin, requireUserSession } from '../middleware/tierGates';
import { requireUserSession } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { parseRequestedTargetVersion } from '../utils/targetVersion';
import type { SelfUpdatePreflight } from '../services/SelfUpdateService';
@@ -26,7 +27,7 @@ licenseRouter.get('/', (_req: Request, res: Response): void => {
licenseRouter.post('/activate', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:license')) return;
try {
const { license_key } = req.body;
if (!license_key || typeof license_key !== 'string') {
@@ -47,7 +48,7 @@ licenseRouter.post('/activate', async (req: Request, res: Response): Promise<voi
licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:license')) return;
try {
const result = await LicenseService.getInstance().deactivate();
if (result.success) {
@@ -121,7 +122,7 @@ export function scheduleLocalUpdate(res: Response, message: string, targetVersio
export const systemUpdateRouter = Router();
systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:license')) return;
const selfUpdate = SelfUpdateService.getInstance();
if (!selfUpdate.isAvailable()) {
res.status(503).json({ error: 'Self-update unavailable. Sencho must be deployed via Docker Compose.' });
@@ -177,7 +178,7 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise<
});
systemUpdateRouter.post('/reapply-compose', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:license')) return;
const selfUpdate = SelfUpdateService.getInstance();
if (!selfUpdate.isAvailable()) {
res.status(503).json({ error: 'Compose reapply unavailable. Sencho must be deployed via Docker Compose.' });
+9 -8
View File
@@ -1,7 +1,8 @@
import { Router, type Request, type Response } from 'express';
import { RegistryService } from '../services/RegistryService';
import { listRegistryTagsResult, type TagListCode } from '../services/registry-api';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requirePaid } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
@@ -70,7 +71,7 @@ export const registriesRouter = Router();
registriesRouter.get('/', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
res.json(RegistryService.getInstance().getAll());
} catch (error) {
@@ -81,7 +82,7 @@ registriesRouter.get('/', (req: Request, res: Response): void => {
registriesRouter.post('/', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const { name, url, type, username, secret, aws_region } = req.body;
@@ -118,7 +119,7 @@ registriesRouter.post('/', (req: Request, res: Response): void => {
registriesRouter.put('/:id', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
@@ -156,7 +157,7 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => {
registriesRouter.delete('/:id', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
@@ -178,7 +179,7 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => {
// log the browser session out via the frontend unauthorized handler).
registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
@@ -248,7 +249,7 @@ registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<v
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
@@ -267,7 +268,7 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<
registriesRouter.post('/test', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:registries')) return;
try {
const { type, url, username, secret, aws_region } = req.body;
+85 -39
View File
@@ -2,46 +2,91 @@ import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { DatabaseService } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requirePaid } from '../middleware/tierGates';
import { requirePermission, checkPermission, type PermissionAction } from '../middleware/permissions';
import { parseNotificationDispatchRetries } from '../helpers/notificationDispatchRetries';
// Strict allowlist of keys readable and writable via the generic settings
// API. This is the single source of truth for what the endpoint exposes:
// reads project only these keys, so secrets written to global_settings by
// other subsystems (the cloud_backup_* credentials stored by the cloud-backup
// route, the auth_* login secrets) are never returned here; writes are
// rejected for anything outside the list.
const ALLOWED_SETTING_KEYS = new Set([
'host_cpu_limit',
'host_ram_limit',
'host_disk_limit',
'host_alerts_enabled',
'host_alert_suppression_mins',
'docker_janitor_gb',
'global_crash',
'developer_mode',
'template_registry_url',
'metrics_retention_hours',
'log_retention_days',
'audit_retention_days',
'mesh_auto_recreate',
'scan_history_per_image_limit',
'prune_orphaned_scans',
'prune_on_update',
'reclaim_hero',
'snapshot_documentation',
'health_gate_enabled',
'health_gate_window_seconds',
'env_block_deploy_on_missing_required',
'auto_create_missing_external_networks',
'image_update_sidebar_indicators',
'notification_dispatch_retries',
'session_sliding_refresh',
]);
// Allowlist of keys readable/writable via the generic settings API, each
// mapped to the permission required to write it. Reads project only these
// keys so secrets written to global_settings by other subsystems (cloud
// backup credentials, auth_* login secrets) are never returned; writes
// outside the map are rejected.
const SETTING_WRITE_PERMISSIONS: Record<string, PermissionAction> = {
host_cpu_limit: 'node:manage',
host_ram_limit: 'node:manage',
host_disk_limit: 'node:manage',
host_alerts_enabled: 'node:manage',
host_alert_suppression_mins: 'node:manage',
docker_janitor_gb: 'node:manage',
global_crash: 'node:manage',
template_registry_url: 'node:manage',
prune_on_update: 'node:manage',
reclaim_hero: 'node:manage',
health_gate_enabled: 'node:manage',
health_gate_window_seconds: 'node:manage',
env_block_deploy_on_missing_required: 'node:manage',
auto_create_missing_external_networks: 'node:manage',
notification_dispatch_retries: 'node:manage',
developer_mode: 'system:settings',
metrics_retention_hours: 'system:settings',
log_retention_days: 'system:settings',
audit_retention_days: 'system:settings',
mesh_auto_recreate: 'system:settings',
scan_history_per_image_limit: 'system:settings',
prune_orphaned_scans: 'system:settings',
snapshot_documentation: 'system:settings',
image_update_sidebar_indicators: 'system:settings',
session_sliding_refresh: 'system:settings',
};
// Keys whose write requires a paid license, not just an admin role.
const ALLOWED_SETTING_KEYS = new Set(Object.keys(SETTING_WRITE_PERMISSIONS));
/** Resolve node:manage against the active node so scoped Node Admin grants apply. */
function checkNodeManage(req: Request): boolean {
const nodeId = req.nodeId;
if (typeof nodeId === 'number') {
return checkPermission(req, 'node:manage', 'node', String(nodeId));
}
return checkPermission(req, 'node:manage');
}
function requireNodeManage(req: Request, res: Response): boolean {
const nodeId = req.nodeId;
if (typeof nodeId === 'number') {
return requirePermission(req, res, 'node:manage', 'node', String(nodeId));
}
return requirePermission(req, res, 'node:manage');
}
/** Fail closed if any key lacks its required permission. */
function requireSettingsWritePermission(req: Request, res: Response, keys: string[]): boolean {
// Empty no-op still requires write capability (prior requireAdmin behavior).
if (keys.length === 0) {
if (checkNodeManage(req) || checkPermission(req, 'system:settings')) return true;
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return false;
}
const needed = new Set<PermissionAction>();
for (const key of keys) {
const action = SETTING_WRITE_PERMISSIONS[key];
if (!action) {
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
return false;
}
needed.add(action);
}
for (const action of needed) {
const ok = action === 'node:manage'
? requireNodeManage(req, res)
: requirePermission(req, res, action);
if (!ok) return false;
}
return true;
}
// Keys whose write requires a paid license, not just a permission.
// audit_retention_days configures the paid audit log, so a Community admin
// must not be able to set it.
// must not be able to set it. Checked after the permission bucket.
const PAID_ONLY_SETTING_KEYS = new Set(['audit_retention_days']);
// Bulk PATCH schema. All keys optional; present keys are fully validated.
@@ -102,13 +147,13 @@ settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Pr
});
settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { key, value } = req.body;
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
return;
}
if (!requireSettingsWritePermission(req, res, [key])) return;
if (PAID_ONLY_SETTING_KEYS.has(key) && !requirePaid(req, res)) return;
if (value === undefined || value === null) {
res.status(400).json({ error: 'Setting value is required' });
@@ -146,7 +191,6 @@ settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
});
settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
// Reject unknown/disallowed keys outright rather than letting Zod silently
// strip them. This keeps the bulk path fail-closed and consistent with the
@@ -165,7 +209,9 @@ settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): P
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
return;
}
if (Object.keys(parsed.data).some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return;
const keys = Object.keys(parsed.data);
if (!requireSettingsWritePermission(req, res, keys)) return;
if (keys.some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return;
const db = DatabaseService.getInstance();
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
for (const [k, v] of entries) {
+10 -9
View File
@@ -2,7 +2,8 @@ import { Router, type Request, type Response } from 'express';
import bcrypt from 'bcrypt';
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { requirePaid } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
@@ -29,7 +30,7 @@ export const usersRouter = Router();
usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
try {
const db = DatabaseService.getInstance();
const users = db.getUsers();
@@ -47,7 +48,7 @@ usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promis
usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
try {
const { username, password, role } = req.body;
@@ -91,7 +92,7 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
// to manage existing users even if their license lapses.
usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
@@ -166,7 +167,7 @@ usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pro
usersRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
try {
const id = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
@@ -202,7 +203,7 @@ usersRouter.delete('/:id', authMiddleware, async (req: Request, res: Response):
*/
usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
try {
const id = parseIntParam(req, res, 'id', 'user id');
if (id === null) return;
@@ -232,7 +233,7 @@ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response)
usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
if (!requirePaid(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
@@ -251,7 +252,7 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi
usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
if (!requirePaid(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
@@ -357,7 +358,7 @@ usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Respons
usersRouter.delete('/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:users')) return;
if (!requirePaid(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
+4 -4
View File
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
import { DatabaseService, type WebhookAction } from '../services/DatabaseService';
import { WebhookService } from '../services/WebhookService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { webhookTriggerLimiter } from '../middleware/rateLimiters';
const VALID_WEBHOOK_ACTIONS: readonly WebhookAction[] = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
@@ -26,7 +26,7 @@ webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Pro
});
webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:webhooks')) return;
try {
const { name, stack_name, action, enabled, node_id } = req.body;
if (!name || !stack_name || !action) {
@@ -71,7 +71,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
});
webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:webhooks')) return;
try {
const id = parseInt(req.params.id as string, 10);
const webhook = DatabaseService.getInstance().getWebhook(id);
@@ -114,7 +114,7 @@ webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response):
});
webhooksRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'system:webhooks')) return;
try {
const id = parseInt(req.params.id as string, 10);
DatabaseService.getInstance().deleteWebhook(id);