From a3026f47a8291c6b4d0a554b7aa4bde17c3ef32b Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 30 Jul 2026 10:25:13 -0400 Subject: [PATCH] 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. --- .../settings-permission-authz.test.ts | 308 ++++++++++++++++++ backend/src/__tests__/users-rbac.test.ts | 4 +- backend/src/routes/agents.ts | 6 +- backend/src/routes/apiTokens.ts | 8 +- backend/src/routes/imageUpdates.ts | 7 +- backend/src/routes/license.ts | 11 +- backend/src/routes/registries.ts | 17 +- backend/src/routes/settings.ts | 124 ++++--- backend/src/routes/users.ts | 19 +- backend/src/routes/webhooks.ts | 8 +- docs/features/rbac.mdx | 4 +- docs/reference/settings.mdx | 2 +- .../hooks/useSidebarContextMenu.ts | 4 +- .../src/components/mobile/MobileSettings.tsx | 8 +- .../components/settings/AppStoreSection.tsx | 7 +- .../settings/ContainerAlertsSection.tsx | 7 +- .../settings/DataRetentionSection.tsx | 6 +- .../components/settings/DeveloperSection.tsx | 6 +- .../settings/DockerStorageSection.tsx | 7 +- .../components/settings/FleetMeshSection.tsx | 1 + .../components/settings/HostAlertsSection.tsx | 7 +- .../settings/NotificationsSection.tsx | 8 +- .../src/components/settings/SectionGate.tsx | 17 +- .../src/components/settings/SettingsPage.tsx | 20 +- .../components/settings/SettingsSidebar.tsx | 18 +- .../src/components/settings/StacksSection.tsx | 7 +- .../components/settings/UpdatesSection.tsx | 4 +- .../components/settings/WebhooksSection.tsx | 15 +- .../__tests__/NotificationsSection.test.tsx | 7 +- .../__tests__/SectionSavePayloads.test.tsx | 9 +- .../__tests__/SettingsDirtyReconcile.test.tsx | 9 +- .../__tests__/SettingsLoadFailures.test.tsx | 9 +- .../settings/__tests__/StacksSection.test.tsx | 29 +- .../__tests__/UpdatesSection.test.tsx | 7 +- .../settings/__tests__/registry.test.ts | 35 +- .../__tests__/rolePermissionsFixture.ts | 24 ++ .../settingsVisibilityMatrix.test.ts | 77 +++++ frontend/src/components/settings/registry.ts | 19 +- .../settings/useSettingsVisibility.ts | 18 + .../src/components/sidebar/sidebar-types.ts | 2 + .../__tests__/useStackMenuItems.test.tsx | 13 + .../src/hooks/useStackKeyboardShortcuts.ts | 1 + frontend/src/hooks/useStackMenuItems.tsx | 8 +- frontend/src/lib/canManageNode.ts | 19 ++ frontend/src/lib/routing/reachability.test.ts | 33 ++ frontend/src/lib/routing/reachability.ts | 13 +- 46 files changed, 812 insertions(+), 180 deletions(-) create mode 100644 backend/src/__tests__/settings-permission-authz.test.ts create mode 100644 frontend/src/components/settings/__tests__/rolePermissionsFixture.ts create mode 100644 frontend/src/components/settings/__tests__/settingsVisibilityMatrix.test.ts create mode 100644 frontend/src/components/settings/useSettingsVisibility.ts create mode 100644 frontend/src/lib/canManageNode.ts diff --git a/backend/src/__tests__/settings-permission-authz.test.ts b/backend/src/__tests__/settings-permission-authz.test.ts new file mode 100644 index 00000000..634a55f7 --- /dev/null +++ b/backend/src/__tests__/settings-permission-authz.test.ts @@ -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> = {}; + +async function seedAndLogin(role: UserRole): Promise { + 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'); + }); +}); diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index 8facd099..d2777e71 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -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'); }); }); diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index 527ca960..b7994307 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -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 => { - 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; diff --git a/backend/src/routes/apiTokens.ts b/backend/src/routes/apiTokens.ts index 3267ec67..b03ea19b 100644 --- a/backend/src/routes/apiTokens.ts +++ b/backend/src/routes/apiTokens.ts @@ -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 => { 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 => { 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 => { 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; diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index e3c000ae..7936ffd0 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -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' }); diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts index e640c6c9..54fd3acb 100644 --- a/backend/src/routes/license.ts +++ b/backend/src/routes/license.ts @@ -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 => { 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 => { 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 => { - 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 => { - 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.' }); diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index 42cbef4f..c25ec61e 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -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 => { 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 => { 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 => { 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; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1370c8e7..fcbae7c9 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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 = { + 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(); + 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 => { - 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 => { - 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) { diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 7321f3d9..0856edb1 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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 => { 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 => { 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 => { 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 => { 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 => { 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); diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index e7435e83..f9b97d62 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -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 => { - 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 => { - 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 => { - 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); diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 8efb4f74..3eee2c61 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -21,7 +21,7 @@ Sencho ships with five built-in roles that map to the permissions most operators | **Admin** | Full operator access: deploy, edit compose, manage users, configure nodes, view audit log, every system setting | Community | | **Viewer** | Read-only access to stacks, logs, stats, file contents, and node listings | Community | | **Deployer** | Deploy, restart, stop, and start stacks. Cannot edit compose files, create or delete stacks, or view nodes | Admiral | -| **Node Admin** | Full stack and node management across the fleet. No access to system settings, users, or license | Admiral | +| **Node Admin** | Full stack and node management across the fleet, including node-scoped operational Settings. No access to users, licensing, credentials, or system-only Settings | Admiral | | **Auditor** | Read-only access to stacks, nodes, and the audit log. No write access anywhere | Admiral | ### Permission matrix @@ -138,7 +138,7 @@ Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `fr - A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack at node `prod` can deploy, restart, and stop only that stack on `prod`. The same name on another node needs its own grant. They cannot edit compose or delete it. - A **Deployer** with a scoped **Node Admin** assignment on node `staging-server` can manage every stack and node operation on that server, while keeping plain Deployer rights on the rest of the fleet. -- A **Node Admin** without any scoped assignments has full stack and node management across every node, but still cannot reach system settings, the user list, or the audit log. +- A **Node Admin** without any scoped assignments has full stack and node management across every node, including node-scoped operational Settings, but still cannot reach users, licensing, credentials, or system-only Settings. ## Two-factor reset diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 1a41b03d..cb5685a6 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -207,7 +207,7 @@ Create and manage user accounts with role-based access. The masthead publishes a | **Admin** | Community | Full access to all features | | **Viewer** | Community | Read-only access to stacks and nodes | | **Deployer** | Admiral | Can view stacks and trigger deployments | -| **Node Admin** | Admiral | Full stack and node management, no system settings | +| **Node Admin** | Admiral | Full stack and node management, including node-scoped operational Settings | | **Auditor** | Admiral | Read-only plus audit log access | See [RBAC & User Management](/features/rbac) for details on what each role can access. diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 11ac09a8..3cefcb23 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -20,6 +20,7 @@ import type { useViewNavigationState } from './useViewNavigationState'; import type { Node } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext'; import type { PermissionAction } from '@/context/AuthContext'; +import { canManageNode } from '@/lib/canManageNode'; type StackListState = ReturnType; type NavState = ReturnType; @@ -74,6 +75,7 @@ export function useSidebarContextMenu({ menuVisibility: stackActions.getStackMenuVisibility(file), openAlertSheet: () => overlayState.openAlertSheet(file), openAutoHeal: () => overlayState.openAutoHeal(file), + canCheckUpdates: canManageNode(can, nodeId), checkUpdates: () => stackActions.checkUpdatesForStack(), openStackApp: () => stackActions.openStackApp(file), deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), @@ -179,7 +181,7 @@ export function useSidebarContextMenu({ // deps would force a rebuild on every parent render and defeat the memo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, + stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, can, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id, hasCapability, navState.openMuteRulesWithPrefill, diff --git a/frontend/src/components/mobile/MobileSettings.tsx b/frontend/src/components/mobile/MobileSettings.tsx index 08fbbb98..e635a78d 100644 --- a/frontend/src/components/mobile/MobileSettings.tsx +++ b/frontend/src/components/mobile/MobileSettings.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; -import { useAuth } from '@/context/AuthContext'; -import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { SETTINGS_GROUPS, @@ -13,6 +11,7 @@ import { } from '@/components/settings'; import type { SectionId } from '@/components/settings'; import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent'; +import { useSettingsVisibility } from '@/components/settings/useSettingsVisibility'; import { BackChip, Kicker, Masthead } from './mobile-ui'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; @@ -31,12 +30,9 @@ export function MobileSettings({ onSelectedSectionChange, quickLinkCandidates, }: MobileSettingsProps) { - const { isAdmin } = useAuth(); - const { isPaid } = useLicense(); const { activeNode } = useNodes(); - const isRemote = activeNode?.type === 'remote'; const nodeName = activeNode?.name ?? 'local'; - const visibility = { isRemote, isAdmin, isPaid }; + const visibility = useSettingsVisibility(); const visibleItems = SETTINGS_ITEMS.filter( item => isItemVisible(item, visibility) && !isItemLocked(item, visibility), diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx index 4f69e03a..23362722 100644 --- a/frontend/src/components/settings/AppStoreSection.tsx +++ b/frontend/src/components/settings/AppStoreSection.tsx @@ -6,6 +6,7 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; +import { canManageNode } from '@/lib/canManageNode'; import { RefreshCw } from 'lucide-react'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; @@ -23,9 +24,9 @@ function SectionSkeleton() { } export function AppStoreSection() { - const { isAdmin } = useAuth(); + const { can } = useAuth(); const { activeNode } = useNodes(); - const readOnly = !isAdmin; + const readOnly = !canManageNode(can, activeNode?.id); const [templateRegistryUrl, setTemplateRegistryUrl] = useState(''); const serverUrl = useRef(''); const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id); @@ -115,7 +116,7 @@ export function AppStoreSection() { /> - + {!readOnly && (