mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 00:18:00 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof useStackListState>;
|
||||
type NavState = ReturnType<typeof useViewNavigationState>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsActions align="between" hint={readOnly ? 'Read-only · admin access required to edit' : (templateRegistryUrl ? 'using custom registry' : 'using default')}>
|
||||
<SettingsActions align="between" hint={readOnly ? 'Read-only · permission required to edit' : (templateRegistryUrl ? 'using custom registry' : 'using default')}>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
@@ -35,9 +36,9 @@ const DEFAULT_CONTAINER_ALERTS: ContainerAlertFields = {
|
||||
};
|
||||
|
||||
export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSectionProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const readOnly = !canManageNode(can, activeNode?.id);
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<ContainerAlertFields>({ ...DEFAULT_CONTAINER_ALERTS });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -121,7 +122,7 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -45,10 +45,10 @@ const DEFAULT_DATA_RETENTION: DataRetentionFields = {
|
||||
};
|
||||
|
||||
export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can, permissionsReady } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const readOnly = !permissionsReady || !can('system:settings');
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -222,7 +222,7 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -37,9 +37,9 @@ const DEFAULT_DEVELOPER: DeveloperFields = {
|
||||
};
|
||||
|
||||
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can, permissionsReady } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const readOnly = !permissionsReady || !can('system:settings');
|
||||
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -130,7 +130,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
import type { PatchableSettings } from './types';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
@@ -41,8 +42,8 @@ const DEFAULT_DOCKER_STORAGE: DockerStorageFields = {
|
||||
|
||||
export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const readOnly = !isAdmin;
|
||||
const { can } = useAuth();
|
||||
const readOnly = !canManageNode(can, activeNode?.id);
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -153,7 +154,7 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const showMesh = experimentalReady && experimental;
|
||||
// Admin role only (section is adminOnly in the registry). Do not swap to can().
|
||||
const readOnly = !isAdmin;
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
import type { PatchableSettings } from './types';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
@@ -44,8 +45,8 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = {
|
||||
|
||||
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const readOnly = !isAdmin;
|
||||
const { can } = useAuth();
|
||||
const readOnly = !canManageNode(can, activeNode?.id);
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -188,7 +189,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { NumberChip } from './SystemControls';
|
||||
import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { parseNotificationDispatchRetries } from '@/lib/notificationDispatchRetries';
|
||||
|
||||
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
@@ -53,9 +54,12 @@ interface NotificationsSectionProps {
|
||||
}
|
||||
|
||||
export function NotificationsSection({ onDirtyChange }: NotificationsSectionProps) {
|
||||
// This section configures outbound notification *channels* (/api/agents), which
|
||||
// require node:manage. Alert routing and mute rules live in separate Settings
|
||||
// sections and stay Admin-only via notifications.ts / adminOnly registry flags.
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const readOnly = !isAdmin;
|
||||
const { can } = useAuth();
|
||||
const readOnly = !canManageNode(can, activeNode?.id);
|
||||
const activeNodeIdRef = useRef(activeNode?.id);
|
||||
useEffect(() => { activeNodeIdRef.current = activeNode?.id; }, [activeNode?.id]);
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { getSettingsItem, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext } from './registry';
|
||||
import { useSettingsVisibility } from './useSettingsVisibility';
|
||||
import type { SectionId } from './types';
|
||||
|
||||
interface SectionGateProps {
|
||||
@@ -18,17 +16,8 @@ interface SectionGateProps {
|
||||
* guards remain the authoritative enforcement.
|
||||
*/
|
||||
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||
const { isAdmin, permissionsStatus } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isRemote,
|
||||
};
|
||||
const { permissionsStatus } = useAuth();
|
||||
const visibility = useSettingsVisibility();
|
||||
|
||||
const item = getSettingsItem(sectionId);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@/components/ui/command';
|
||||
import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import {
|
||||
SETTINGS_ITEMS,
|
||||
@@ -23,11 +22,12 @@ import {
|
||||
isItemLocked,
|
||||
scopeLabel,
|
||||
} from './index';
|
||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||
import type { SectionId, SettingsItemMeta } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
import { useSettingsVisibility } from './useSettingsVisibility';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
interface SettingsPageProps {
|
||||
@@ -55,10 +55,9 @@ function SettingsPageInner({
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
}: SettingsPageProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const visibility = useSettingsVisibility();
|
||||
const { permissionsStatus } = useAuth();
|
||||
|
||||
// Mobile master/detail: below md the nav rail and the section content cannot
|
||||
// sit side by side, so the rail is a full-screen list and choosing a section
|
||||
@@ -72,22 +71,21 @@ function SettingsPageInner({
|
||||
// Desktop shows both panes; mobile shows exactly one (the rail or the section).
|
||||
const showSidebar = !isMobile || !mobileSectionOpen;
|
||||
const showSection = !isMobile || mobileSectionOpen;
|
||||
const visibility: VisibilityContext = useMemo(
|
||||
() => ({ isRemote, isAdmin, isPaid }),
|
||||
[isRemote, isAdmin, isPaid],
|
||||
);
|
||||
|
||||
// Resolve the rendered section: must be a registry id and must be visible to the
|
||||
// current operator. If the current selection points to a hidden section (e.g.,
|
||||
// node-scoped item on a remote, or admin-only item for a non-admin), fall back to
|
||||
// the first visible item.
|
||||
// the first visible item. Defer until permission metadata is ready so deep links
|
||||
// to requiredPermission sections (e.g. license) are not rewritten while can() is
|
||||
// still fail-closed during cold load.
|
||||
const safeSection: SectionId = useMemo(() => {
|
||||
if (permissionsStatus !== 'ready') return currentSection;
|
||||
const reachable = (i: SettingsItemMeta) => isItemVisible(i, visibility) && !isItemLocked(i, visibility);
|
||||
const direct = SETTINGS_ITEMS.find(i => i.id === currentSection);
|
||||
if (direct && reachable(direct)) return direct.id;
|
||||
const fallback = SETTINGS_ITEMS.find(reachable);
|
||||
return fallback?.id ?? 'appearance';
|
||||
}, [currentSection, visibility]);
|
||||
}, [currentSection, visibility, permissionsStatus]);
|
||||
useEffect(() => {
|
||||
if (safeSection !== currentSection) onSectionChange(safeSection);
|
||||
}, [safeSection, currentSection, onSectionChange]);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext, SettingsItemMeta } from './registry';
|
||||
import type { SettingsItemMeta } from './registry';
|
||||
import { useSettingsVisibility } from './useSettingsVisibility';
|
||||
import type { SectionId } from './types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -16,17 +14,7 @@ interface SettingsSidebarProps {
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isRemote,
|
||||
};
|
||||
const visibility = useSettingsVisibility();
|
||||
|
||||
// An item appears in the sidebar only if its registry visibility predicate
|
||||
// passes AND the operator has the entitlement for it. Tier-locked items
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
@@ -58,8 +59,8 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
|
||||
// Node-scoped deploy guardrails
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const readOnly = !isAdmin;
|
||||
const { can } = useAuth();
|
||||
const readOnly = !canManageNode(can, activeNode?.id);
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<GuardrailFields>({ ...DEFAULT_GUARDRAILS });
|
||||
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -238,7 +239,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
<SettingsActions hint={readOnly ? 'Read-only · permission required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveGuardrails} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -46,8 +46,8 @@ function SectionSkeleton() {
|
||||
|
||||
export function UpdatesSection() {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const readOnly = !isAdmin;
|
||||
const { can, permissionsReady } = useAuth();
|
||||
const readOnly = !permissionsReady || !can('system:settings');
|
||||
const [status, setStatus] = useState<ImageUpdateStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -43,7 +43,8 @@ interface WebhookExecution {
|
||||
}
|
||||
|
||||
export function WebhooksSection() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { can } = useAuth();
|
||||
const canManageWebhooks = can('system:webhooks');
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -74,7 +75,7 @@ export function WebhooksSection() {
|
||||
};
|
||||
|
||||
useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]);
|
||||
useEffect(() => { if (!isAdmin) setShowForm(false); }, [isAdmin]);
|
||||
useEffect(() => { if (!canManageWebhooks) setShowForm(false); }, [canManageWebhooks]);
|
||||
|
||||
const enabledCount = webhooks.filter(w => w.enabled).length;
|
||||
useMastheadStats(
|
||||
@@ -160,7 +161,7 @@ export function WebhooksSection() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{isAdmin && (
|
||||
{canManageWebhooks && (
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton size="sm" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="w-4 h-4" /> Create webhook
|
||||
@@ -168,7 +169,7 @@ export function WebhooksSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && showForm && (
|
||||
{canManageWebhooks && showForm && (
|
||||
<SettingsSection title="New webhook">
|
||||
<SettingsField label="Name" helper="Shown in execution history and notifications." htmlFor="webhook-name">
|
||||
<Input id="webhook-name" placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
|
||||
@@ -243,9 +244,9 @@ export function WebhooksSection() {
|
||||
<SettingsCallout
|
||||
icon={<Webhook className="h-4 w-4" />}
|
||||
title="No webhooks yet"
|
||||
subtitle={isAdmin
|
||||
subtitle={canManageWebhooks
|
||||
? 'Create one to trigger stack actions from CI/CD.'
|
||||
: 'An admin operator can create webhooks for this instance.'}
|
||||
: 'An operator with webhook permission can create webhooks for this instance.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -274,7 +275,7 @@ export function WebhooksSection() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isAdmin ? (
|
||||
{canManageWebhooks ? (
|
||||
<>
|
||||
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
|
||||
|
||||
@@ -25,7 +25,12 @@ const { masthead, nodeState } = vi.hoisted(() => ({
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ activeNode: nodeState.activeNode }),
|
||||
}));
|
||||
const authState = { isAdmin: true };
|
||||
const authState = {
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready' as const,
|
||||
can: (action: string) => authState.isAdmin || action === 'never',
|
||||
};
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
@@ -14,7 +14,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => true,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
|
||||
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
|
||||
|
||||
@@ -22,7 +22,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => true,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
|
||||
vi.mock('../MastheadStatsContext', () => ({
|
||||
|
||||
@@ -9,7 +9,14 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => true,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
|
||||
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
|
||||
const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true }));
|
||||
|
||||
@@ -20,7 +20,18 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
const useAuthMock = vi.fn(() => ({ isAdmin: true }));
|
||||
type AuthMock = {
|
||||
isAdmin: boolean;
|
||||
permissionsReady: boolean;
|
||||
permissionsStatus: 'ready';
|
||||
can: (action?: string) => boolean;
|
||||
};
|
||||
const useAuthMock = vi.fn((): AuthMock => ({
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => true,
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
|
||||
@@ -40,7 +51,12 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockedFetch.mockReset();
|
||||
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useAuthMock.mockReturnValue({
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,8 +125,13 @@ describe('StacksSection', () => {
|
||||
expect(screen.getByText('Save settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables guardrails for non-admin while Workflow controls remain enabled', async () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false });
|
||||
it('disables guardrails without node:manage while Workflow controls remain enabled', async () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
isAdmin: false,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready',
|
||||
can: () => false,
|
||||
});
|
||||
render(<StacksSection />);
|
||||
await waitFor(() => expect(screen.getByText('Deploy Guardrails')).toBeInTheDocument());
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
const authState = { isAdmin: true };
|
||||
const authState = {
|
||||
isAdmin: true,
|
||||
permissionsReady: true,
|
||||
permissionsStatus: 'ready' as const,
|
||||
can: (action: string) => authState.isAdmin || action === 'never',
|
||||
};
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
|
||||
|
||||
@@ -91,10 +91,11 @@ describe('settings registry', () => {
|
||||
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'security')).toBe(false);
|
||||
});
|
||||
|
||||
it('opens Registries to Community while keeping it admin-only', () => {
|
||||
it('opens Registries to Community behind system:registries', () => {
|
||||
const registries = SETTINGS_ITEMS.find(i => i.id === 'registries');
|
||||
expect(registries?.tier).toBeNull();
|
||||
expect(registries?.adminOnly).toBe(true);
|
||||
expect(registries?.adminOnly).toBeUndefined();
|
||||
expect(registries?.requiredPermission).toBe('system:registries');
|
||||
});
|
||||
|
||||
it('registers the Stacks section under Infrastructure with searchable workflow keywords', () => {
|
||||
@@ -141,3 +142,33 @@ describe('scopeLabel', () => {
|
||||
expect(scopeLabel(item({ scope: 'global', group: 'access' }))).toBe('global');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredPermission registry mapping', () => {
|
||||
it('declares matrix permissions for access and credential sections', () => {
|
||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||
expect(byId.get('users')?.requiredPermission).toBe('system:users');
|
||||
expect(byId.get('users')?.adminOnly).toBeUndefined();
|
||||
expect(byId.get('license')?.requiredPermission).toBe('system:license');
|
||||
expect(byId.get('api-tokens')?.requiredPermission).toBe('system:tokens');
|
||||
expect(byId.get('api-tokens')?.adminOnly).toBeUndefined();
|
||||
expect(byId.get('webhooks')?.requiredPermission).toBe('system:webhooks');
|
||||
expect(byId.get('nodes')?.requiredPermission).toBe('node:read');
|
||||
expect(byId.get('developer')?.requiredPermission).toBe('system:settings');
|
||||
expect(byId.get('data-retention')?.requiredPermission).toBe('system:settings');
|
||||
expect(byId.get('image-updates')?.requiredPermission).toBe('system:settings');
|
||||
});
|
||||
|
||||
it('keeps adminOnly on identity, credentials, and emergency surfaces', () => {
|
||||
for (const id of [
|
||||
'sso',
|
||||
'cloud-backup',
|
||||
'recovery',
|
||||
'fleet-mesh',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
] as const) {
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === id)?.adminOnly, id).toBe(true);
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === id)?.requiredPermission, id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Mirrors backend ROLE_PERMISSIONS for Settings visibility matrix tests. */
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<string, PermissionAction[]> = {
|
||||
admin: [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
'system:settings', 'system:users', 'system:license', 'system:webhooks',
|
||||
'system:tokens', 'system:console', 'system:audit', 'system:registries',
|
||||
],
|
||||
'node-admin': [
|
||||
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
|
||||
'node:read', 'node:manage',
|
||||
],
|
||||
deployer: [
|
||||
'stack:read', 'stack:deploy',
|
||||
],
|
||||
viewer: [
|
||||
'stack:read', 'node:read',
|
||||
],
|
||||
auditor: [
|
||||
'stack:read', 'node:read', 'system:audit',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Settings visibility: requiredPermission and adminOnly for five built-in roles.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SETTINGS_ITEMS, isItemVisible, type VisibilityContext } from '../registry';
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
import { ROLE_PERMISSIONS } from './rolePermissionsFixture';
|
||||
|
||||
function visibilityFor(role: keyof typeof ROLE_PERMISSIONS, over: Partial<VisibilityContext> = {}): VisibilityContext {
|
||||
const perms = new Set(ROLE_PERMISSIONS[role]);
|
||||
return {
|
||||
isRemote: false,
|
||||
isAdmin: role === 'admin',
|
||||
isPaid: true,
|
||||
can: (action: PermissionAction) => role === 'admin' || perms.has(action),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('settings section visibility by role', () => {
|
||||
const permissionSections = [
|
||||
'users',
|
||||
'license',
|
||||
'api-tokens',
|
||||
'registries',
|
||||
'webhooks',
|
||||
'nodes',
|
||||
] as const;
|
||||
|
||||
it('shows permission-gated sections only to roles that hold the permission', () => {
|
||||
for (const sectionId of permissionSections) {
|
||||
const item = SETTINGS_ITEMS.find(i => i.id === sectionId)!;
|
||||
const perm = item.requiredPermission!;
|
||||
for (const role of Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]) {
|
||||
const visible = isItemVisible(item, visibilityFor(role));
|
||||
const expected = role === 'admin' || ROLE_PERMISSIONS[role].includes(perm);
|
||||
expect(visible, `${sectionId} for ${role}`).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('hides license and webhooks from non-admin roles (visibility correction)', () => {
|
||||
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
|
||||
const ctx = visibilityFor(role);
|
||||
expect(isItemVisible(SETTINGS_ITEMS.find(i => i.id === 'license')!, ctx)).toBe(false);
|
||||
expect(isItemVisible(SETTINGS_ITEMS.find(i => i.id === 'webhooks')!, ctx)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps host-alerts visible to all authenticated roles (editability is separate)', () => {
|
||||
const item = SETTINGS_ITEMS.find(i => i.id === 'host-alerts')!;
|
||||
for (const role of Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]) {
|
||||
expect(isItemVisible(item, visibilityFor(role)), role).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides system:settings sections from roles without that permission', () => {
|
||||
for (const sectionId of ['developer', 'data-retention', 'image-updates'] as const) {
|
||||
const item = SETTINGS_ITEMS.find(i => i.id === sectionId)!;
|
||||
expect(isItemVisible(item, visibilityFor('admin'))).toBe(true);
|
||||
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
|
||||
expect(isItemVisible(item, visibilityFor(role)), `${sectionId} for ${role}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('hides adminOnly sections from every non-admin role', () => {
|
||||
const adminOnly = SETTINGS_ITEMS.filter(i => i.adminOnly);
|
||||
expect(adminOnly.length).toBeGreaterThan(0);
|
||||
for (const item of adminOnly) {
|
||||
for (const role of ['node-admin', 'deployer', 'viewer', 'auditor'] as const) {
|
||||
expect(isItemVisible(item, visibilityFor(role)), `${item.id} for ${role}`).toBe(false);
|
||||
}
|
||||
expect(isItemVisible(item, visibilityFor('admin'))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
import type { SectionId } from './types';
|
||||
|
||||
export type SettingsGroupId =
|
||||
@@ -41,7 +42,10 @@ export interface SettingsItemMeta {
|
||||
keywords: string[];
|
||||
tier: TierGate;
|
||||
scope: Scope;
|
||||
/** Built-in Admin role only (credentials, identity, emergency). Not a matrix permission. */
|
||||
adminOnly?: boolean;
|
||||
/** Matrix permission required to see the section. Independent of adminOnly. */
|
||||
requiredPermission?: PermissionAction;
|
||||
hiddenOnRemote?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,6 +79,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['admiral', 'assurance', 'hardened', 'agpl', 'license', 'activation', 'subscription', 'billing'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
requiredPermission: 'system:license',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
@@ -85,7 +90,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions', 'session', 'sliding refresh', 'stay signed in', 'sign out', 'logout'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
requiredPermission: 'system:users',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
@@ -106,7 +111,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
requiredPermission: 'system:tokens',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
// Infrastructure
|
||||
@@ -118,6 +123,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
requiredPermission: 'node:read',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
@@ -147,7 +153,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
requiredPermission: 'system:registries',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
@@ -239,6 +245,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
requiredPermission: 'system:settings',
|
||||
},
|
||||
{
|
||||
id: 'webhooks',
|
||||
@@ -248,6 +255,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
requiredPermission: 'system:webhooks',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
// Organization
|
||||
@@ -269,6 +277,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['retention', 'metrics', 'logs', 'scans', 'audit', 'history', 'prune', 'window'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
requiredPermission: 'system:settings',
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
@@ -278,6 +287,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
keywords: ['developer', 'debug', 'diagnostics', 'metrics', 'verbose'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
requiredPermission: 'system:settings',
|
||||
},
|
||||
{
|
||||
id: 'recovery',
|
||||
@@ -323,11 +333,14 @@ export interface VisibilityContext {
|
||||
isRemote: boolean;
|
||||
isAdmin: boolean;
|
||||
isPaid: boolean;
|
||||
/** Required so construction sites cannot omit permission checks. */
|
||||
can: (action: PermissionAction) => boolean;
|
||||
}
|
||||
|
||||
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
||||
if (ctx.isRemote && item.hiddenOnRemote) return false;
|
||||
if (item.adminOnly && !ctx.isAdmin) return false;
|
||||
if (item.requiredPermission && !ctx.can(item.requiredPermission)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { VisibilityContext } from './registry';
|
||||
|
||||
/** Shared Settings visibility context for sidebar, gate, and page navigation. */
|
||||
export function useSettingsVisibility(): VisibilityContext {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
return useMemo(
|
||||
() => ({ isRemote, isAdmin, isPaid, can }),
|
||||
[isRemote, isAdmin, isPaid, can],
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface StackMenuCtx {
|
||||
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean; showTakeDown: boolean };
|
||||
openAlertSheet: () => void;
|
||||
openAutoHeal: () => void;
|
||||
/** True when the caller may trigger a stack image-update check (node:manage). */
|
||||
canCheckUpdates: boolean;
|
||||
checkUpdates: () => void;
|
||||
openStackApp: () => void;
|
||||
deploy: () => void;
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
openAlertSheet: vi.fn(),
|
||||
openAutoHeal: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
canCheckUpdates: true,
|
||||
openStackApp: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -84,6 +85,18 @@ describe('useStackMenuItems', () => {
|
||||
expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows Check updates when canCheckUpdates', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: true })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'check-updates')).toBeDefined();
|
||||
});
|
||||
|
||||
it('hides Check updates when !canCheckUpdates', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canCheckUpdates: false })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'check-updates')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('toggles Pin / Unpin label based on isPinned', () => {
|
||||
const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true })));
|
||||
const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false })));
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useStackKeyboardShortcuts(
|
||||
e.preventDefault();
|
||||
ctx.openAutoHeal();
|
||||
} else if (key === 'u') {
|
||||
if (!ctx.canCheckUpdates) return;
|
||||
e.preventDefault();
|
||||
ctx.checkUpdates();
|
||||
} else if (key === 'p') {
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid
|
||||
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
|
||||
const {
|
||||
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
openAlertSheet, openAutoHeal, canCheckUpdates, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel,
|
||||
menuVisibility, openScheduleTask,
|
||||
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||
@@ -35,7 +35,9 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
{ id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet },
|
||||
{ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal },
|
||||
];
|
||||
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
|
||||
if (canCheckUpdates) {
|
||||
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
|
||||
}
|
||||
if (stackStatus === 'running' && canOpenApp) {
|
||||
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
|
||||
}
|
||||
@@ -108,7 +110,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
}, [
|
||||
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
|
||||
showDeploy, showStop, showRestart, showUpdate, showTakeDown,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
openAlertSheet, openAutoHeal, canCheckUpdates, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask,
|
||||
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
type CanFn = (
|
||||
action: PermissionAction,
|
||||
resourceType?: string,
|
||||
resourceId?: string,
|
||||
nodeId?: number | null,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Resolve node:manage for the active node so scoped Node Admin grants apply.
|
||||
* When nodeId is missing, falls back to the unscoped check (same as Auth.can()).
|
||||
*/
|
||||
export function canManageNode(can: CanFn, nodeId: number | null | undefined): boolean {
|
||||
if (nodeId != null) {
|
||||
return can('node:manage', 'node', String(nodeId), nodeId);
|
||||
}
|
||||
return can('node:manage');
|
||||
}
|
||||
@@ -95,4 +95,37 @@ describe('reachability', () => {
|
||||
const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true });
|
||||
expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false);
|
||||
});
|
||||
|
||||
it('defers settings permission hides until authz is ready', () => {
|
||||
const loading = ctx({
|
||||
permissionsStatus: 'loading',
|
||||
isAdmin: false,
|
||||
can: () => false,
|
||||
});
|
||||
expect(isSettingsSectionHidden('webhooks', loading)).toBe(false);
|
||||
expect(isSettingsSectionHidden('license', loading)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides requiredPermission sections when the operator lacks the permission', () => {
|
||||
const nodeAdmin = ctx({
|
||||
isAdmin: false,
|
||||
can: (a) => a === 'node:read' || a === 'node:manage',
|
||||
});
|
||||
expect(isSettingsSectionHidden('webhooks', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('license', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('users', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('api-tokens', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('registries', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('nodes', nodeAdmin)).toBe(false);
|
||||
expect(isSettingsSectionHidden('host-alerts', nodeAdmin)).toBe(false);
|
||||
expect(isSettingsSectionHidden('developer', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('data-retention', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('image-updates', nodeAdmin)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides adminOnly settings sections for non-admins', () => {
|
||||
const nodeAdmin = ctx({ isAdmin: false, can: () => true });
|
||||
expect(isSettingsSectionHidden('sso', nodeAdmin)).toBe(true);
|
||||
expect(isSettingsSectionHidden('recovery', nodeAdmin)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import { getSettingsItem } from '@/components/settings/registry';
|
||||
import { getSettingsItem, isItemVisible, isItemLocked } from '@/components/settings/registry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
|
||||
|
||||
@@ -75,9 +75,14 @@ export function isSettingsSectionHidden(section: SectionId, ctx: ReachabilityCon
|
||||
if (!authzReady(ctx)) return false;
|
||||
const item = getSettingsItem(section);
|
||||
if (!item) return true;
|
||||
if (ctx.isRemote && item.hiddenOnRemote) return true;
|
||||
if (item.adminOnly && !ctx.isAdmin) return true;
|
||||
if (item.tier === 'paid' && !ctx.isPaid) return true;
|
||||
const visibility = {
|
||||
isRemote: ctx.isRemote,
|
||||
isAdmin: ctx.isAdmin,
|
||||
isPaid: ctx.isPaid,
|
||||
can: ctx.can,
|
||||
};
|
||||
if (!isItemVisible(item, visibility)) return true;
|
||||
if (isItemLocked(item, visibility)) return true;
|
||||
// fleet-mesh stays reachable: snapshot_documentation lives there even when
|
||||
// Mesh discovery is off.
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user