diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index 693da057..e23e7804 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -4,18 +4,35 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; +import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; let authCookie: string; let viewerCookie: string; +type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor'; + +/** Seed a user with the given role and return a signed bearer token for it. */ +async function seedRoleToken(username: string, role: SeedRole): Promise { + const db = DatabaseService.getInstance(); + let user = db.getUserByUsername(username); + if (!user) { + const hash = await bcrypt.hash('password123', 1); + db.addUser({ username, password_hash: hash, role }); + user = db.getUserByUsername(username); + } + return jwt.sign({ username, role, tv: user!.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); +} + beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); + ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); // Mock LicenseService so paid-gated routes are accessible const { LicenseService } = await import('../services/LicenseService'); @@ -67,6 +84,23 @@ describe('GET /api/alerts', () => { expect(res.body.length).toBe(1); expect(res.body[0].stack_name).toBe('web'); }); + + it('denies a role without stack:read with 403 PERMISSION_DENIED', async () => { + // Every shipped role carries stack:read, so the denial path is exercised + // by temporarily removing it from viewer at runtime, proving the added + // gate actually runs rather than being a no-op. + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get('/api/alerts') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } + }); }); // --- POST /api/alerts --- @@ -79,13 +113,30 @@ describe('POST /api/alerts', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { - const res = await request(app) - .post('/api/alerts') - .set('Cookie', viewerCookie) - .send({ stack_name: 'test', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); - expect(res.status).toBe(403); - }); + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)', + async (role) => { + const token = await seedRoleToken(`alerts-post-${role}`, role); + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${token}`) + .send({ stack_name: 'perm-gate-post', metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s pass the permission gate', + async (role) => { + const token = await seedRoleToken(`alerts-post-${role}`, role); + const res = await request(app) + .post('/api/alerts') + .set('Authorization', `Bearer ${token}`) + .send({ stack_name: `perm-gate-post-${role}`, metric: 'cpu_percent', operator: '>', threshold: 80, duration_mins: 5, cooldown_mins: 60 }); + expect(res.status).toBe(201); + }, + ); it('creates alert and returns 201 with created resource', async () => { const payload = { @@ -282,11 +333,107 @@ describe('DELETE /api/alerts/:id', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s with 403 PERMISSION_DENIED (lacks stack:edit)', + async (role) => { + const alert = DatabaseService.getInstance().addStackAlert({ + stack_name: `delete-gate-deny-${role}`, + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const token = await seedRoleToken(`alerts-delete-${role}`, role); + const res = await request(app) + .delete(`/api/alerts/${alert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s delete', + async (role) => { + const alert = DatabaseService.getInstance().addStackAlert({ + stack_name: `delete-gate-allow-${role}`, + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const token = await seedRoleToken(`alerts-delete-${role}`, role); + const res = await request(app) + .delete(`/api/alerts/${alert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }, + ); + + it('returns 404 for a nonexistent alert id', async () => { const res = await request(app) - .delete('/api/alerts/1') - .set('Cookie', viewerCookie); - expect(res.status).toBe(403); + .delete('/api/alerts/99999') + .set('Cookie', authCookie); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Alert not found' }); + }); + + it("authorizes against the alert's own stack, not a caller's scoped grant on a different stack", async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'alerts-scoped-editor', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ + user_id: userId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'scoped-allowed-stack', + node_id: defaultNodeId, + }); + const user = db.getUserByUsername('alerts-scoped-editor')!; + const token = jwt.sign({ username: user.username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); + + try { + // The scoped grant only covers 'scoped-allowed-stack', so an alert + // belonging to a different stack must still be denied. + const deniedAlert = db.addStackAlert({ + stack_name: 'scoped-other-stack', + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const deniedRes = await request(app) + .delete(`/api/alerts/${deniedAlert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(deniedRes.status).toBe(403); + expect(deniedRes.body.code).toBe('PERMISSION_DENIED'); + + const allowedAlert = db.addStackAlert({ + stack_name: 'scoped-allowed-stack', + service_name: null, + metric: 'cpu_percent', + operator: '>', + threshold: 90, + duration_mins: 0, + cooldown_mins: 0, + }); + const allowedRes = await request(app) + .delete(`/api/alerts/${allowedAlert.id}`) + .set('Authorization', `Bearer ${token}`); + expect(allowedRes.status).toBe(200); + expect(allowedRes.body.success).toBe(true); + } finally { + db.deleteRoleAssignmentsByUser(userId); + db.deleteUser(userId); + } }); it('deletes existing alert rule', async () => { diff --git a/backend/src/__tests__/auto-heal-routes.test.ts b/backend/src/__tests__/auto-heal-routes.test.ts index 962290a0..74158157 100644 --- a/backend/src/__tests__/auto-heal-routes.test.ts +++ b/backend/src/__tests__/auto-heal-routes.test.ts @@ -11,6 +11,9 @@ let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; + +type SeedRole = 'admin' | 'node-admin' | 'deployer' | 'viewer' | 'auditor'; function userToken(username: string): string { const user = DatabaseService.getInstance().getUserByUsername(username); @@ -18,6 +21,14 @@ function userToken(username: string): string { return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); } +/** Seed a user with the given role if it doesn't already exist. */ +async function seedRoleUser(username: string, role: SeedRole): Promise { + const db = DatabaseService.getInstance(); + if (db.getUserByUsername(username)) return; + const hash = await bcrypt.hash('password123', 1); + db.addUser({ username, password_hash: hash, role }); +} + function createApiToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string { const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); @@ -57,6 +68,7 @@ beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); ({ LicenseService } = await import('../services/LicenseService')); + ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); const viewerHash = await bcrypt.hash('password123', 1); @@ -132,17 +144,52 @@ describe('/api/auto-heal routes', () => { expect(res.body.proxy_entitled_until).toBe(0); }); - it('rejects non-admin policy mutation', async () => { - const res = await request(app) - .post('/api/auto-heal/policies') - .set('Authorization', `Bearer ${userToken('route-viewer')}`) - .send({ - stack_name: 'route-stack', - unhealthy_duration_mins: 5, - }); + it.each(['viewer', 'deployer', 'auditor'] as const)( + 'rejects %s policy mutation with 403 PERMISSION_DENIED', + async (role) => { + await seedRoleUser(`auto-heal-post-${role}`, role); + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken(`auto-heal-post-${role}`)}`) + .send({ + stack_name: 'route-stack', + unhealthy_duration_mins: 5, + }); - expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIN_REQUIRED'); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }, + ); + + it.each(['admin', 'node-admin'] as const)( + 'lets %s pass the policy mutation permission gate', + async (role) => { + await seedRoleUser(`auto-heal-post-${role}`, role); + const res = await request(app) + .post('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken(`auto-heal-post-${role}`)}`) + .send({ + stack_name: `route-stack-${role}`, + unhealthy_duration_mins: 5, + }); + + expect(res.status).toBe(201); + }, + ); + + it('denies policy listing with 403 PERMISSION_DENIED when the caller lacks stack:read', async () => { + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get('/api/auto-heal/policies') + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } }); it('lists only policies for the active node', async () => { @@ -178,6 +225,45 @@ describe('/api/auto-heal routes', () => { expect(res.status).toBe(404); }); + it('denies history access with 403 PERMISSION_DENIED when the caller lacks stack:read', async () => { + // Every shipped role carries stack:read, so the denial path is exercised + // by temporarily removing it from viewer at runtime. This proves the + // permission check that was previously entirely absent from this route + // actually runs. + const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; + const policy = makePolicy(defaultNodeId, 'history-perm-gate-stack'); + const original = ROLE_PERMISSIONS.viewer; + ROLE_PERMISSIONS.viewer = original.filter((p) => p !== 'stack:read'); + try { + const res = await request(app) + .get(`/api/auto-heal/policies/${policy.id}/history`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.viewer = original; + } + }); + + it('returns 404 for a wrong-node policy id before the permission check runs', async () => { + // A viewer lacks stack:edit, so if the permission check ran before the + // node-ownership check, this would 403 PERMISSION_DENIED instead of 404. + const secondNodeId = insertLegacyLocal('ordering-second-local'); + const policy = makePolicy(secondNodeId, 'ordering-stack'); + + const patchRes = await request(app) + .patch(`/api/auto-heal/policies/${policy.id}`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`) + .send({ enabled: 0 }); + expect(patchRes.status).toBe(404); + + const deleteRes = await request(app) + .delete(`/api/auto-heal/policies/${policy.id}`) + .set('Authorization', `Bearer ${userToken('route-viewer')}`); + expect(deleteRes.status).toBe(404); + }); + it('persists enabled toggles through the patch route', async () => { const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; const policy = makePolicy(defaultNodeId, 'toggle-stack'); diff --git a/backend/src/__tests__/image-updates-routes.test.ts b/backend/src/__tests__/image-updates-routes.test.ts index cf4019fe..1505e5eb 100644 --- a/backend/src/__tests__/image-updates-routes.test.ts +++ b/backend/src/__tests__/image-updates-routes.test.ts @@ -3,10 +3,13 @@ * Locks down auth, admin gating, rate limiting, and input validation * before extraction. */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; @@ -14,6 +17,24 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic let adminCookie: string; let viewerCookie: string; +/** Sign a JWT for an already-seeded user, using their live token_version. */ +function userToken(username: string): string { + const user = DatabaseService.getInstance().getUserByUsername(username); + if (!user) throw new Error(`missing test user ${username}`); + return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' }); +} + +/** Write a minimal on-disk stack so FileSystemService.getStacks() resolves it. */ +function makeOnDiskStack(name: string): void { + const composeDir = process.env.COMPOSE_DIR as string; + fs.mkdirSync(path.join(composeDir, name), { recursive: true }); + fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n'); +} + +function removeOnDiskStack(name: string): void { + fs.rmSync(path.join(process.env.COMPOSE_DIR as string, name), { recursive: true, force: true }); +} + beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); @@ -29,6 +50,12 @@ beforeAll(async () => { const viewerRes = await request(app).post('/api/auth/login').send({ username: 'iu-viewer', password: 'viewerpass' }); const cookies = viewerRes.headers['set-cookie'] as string | string[]; viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; + + const deployerHash = await bcrypt.hash('deployerpass', 1); + DatabaseService.getInstance().addUser({ username: 'iu-deployer', password_hash: deployerHash, role: 'deployer' }); + + const nodeAdminHash = await bcrypt.hash('nodeadminpass', 1); + DatabaseService.getInstance().addUser({ username: 'iu-node-admin', password_hash: nodeAdminHash, role: 'node-admin' }); }); afterAll(() => cleanupTestDb(tmpDir)); @@ -98,6 +125,135 @@ describe('POST /api/image-updates/refresh', () => { }); }); +describe('POST /api/image-updates/refresh/:stackName', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).post('/api/image-updates/refresh/some-stack'); + expect(res.status).toBe(401); + }); + + it('rejects an invalid stack name with 400', async () => { + const res = await request(app) + .post(`/api/image-updates/refresh/${encodeURIComponent('bad name')}`) + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid stack name/); + }); + + it('rejects a role without stack:deploy with 403 PERMISSION_DENIED', async () => { + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Deployer to trigger a per-stack recheck', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!; + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ outcome: 'cleared', warning: null }); + expect(recheckSpy).toHaveBeenCalledWith(nodeId, 'per-stack-refresh'); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('returns 409 with enabled false when checks are disabled', async () => { + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0'); + const res = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(res.status).toBe(409); + expect(res.body.enabled).toBe(false); + expect(res.body.error).toMatch(/disabled/i); + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1'); + }); + + describe('rate limit', () => { + beforeEach(async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + ImageUpdateService.getInstance().resetStackRecheckCooldowns(); + vi.useFakeTimers().setSystemTime(Date.now()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('rejects a second recheck within the cooldown window with 429', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const first = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(first.status).toBe(200); + + // Within the same cooldown window (2 min), a second call is denied. + vi.advanceTimersByTime(1_000); + const second = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(second.status).toBe(429); + expect(second.body.error).toMatch(/too recently/i); + expect(recheckSpy).toHaveBeenCalledTimes(1); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('allows a recheck after the cooldown window expires', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const first = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(first.status).toBe(200); + + // Advance past the 2-minute cooldown. + vi.advanceTimersByTime(2 * 60 * 1000 + 1); + const second = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(second.status).toBe(200); + expect(recheckSpy).toHaveBeenCalledTimes(2); + } finally { + recheckSpy.mockRestore(); + } + }); + + it('enforces the rate limit independently per-stack', async () => { + const { ImageUpdateService } = await import('../services/ImageUpdateService'); + const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack') + .mockResolvedValue({ outcome: 'cleared', warning: null }); + try { + const a1 = await request(app) + .post('/api/image-updates/refresh/per-stack-refresh') + .set('Cookie', adminCookie); + expect(a1.status).toBe(200); + + // A different stack should not be rate-limited by the first. + const b1 = await request(app) + .post('/api/image-updates/refresh/other-stack') + .set('Cookie', adminCookie); + expect(b1.status).toBe(200); + expect(recheckSpy).toHaveBeenCalledTimes(2); + } finally { + recheckSpy.mockRestore(); + } + }); + }); +}); + describe('GET /api/image-updates/status', () => { it('rejects unauthenticated requests with 401', async () => { const res = await request(app).get('/api/image-updates/status'); @@ -309,11 +465,12 @@ describe('GET /api/image-updates/fleet', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { - // The cross-node aggregation is part of the admin-only readiness surface; - // the single-node GET / endpoint stays open for the sidebar update dot. + it('allows a non-admin authenticated user (auth-only, matching GET / and /detail)', async () => { + // The cross-node aggregation used to be admin-only; it now matches the + // auth-only read model shared with GET /, /detail, and /status. const res = await request(app).get('/api/image-updates/fleet').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + expect(res.status).toBe(200); + expect(res.body).toBeInstanceOf(Object); }); it('returns the fleet-wide aggregation map', async () => { @@ -334,6 +491,22 @@ describe('POST /api/image-updates/fleet/refresh', () => { expect(res.status).toBe(403); }); + it('rejects a Deployer with 403 PERMISSION_DENIED (requires node:manage)', async () => { + const res = await request(app) + .post('/api/image-updates/fleet/refresh') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Node Admin (holds node:manage)', async () => { + const res = await request(app) + .post('/api/image-updates/fleet/refresh') + .set('Authorization', `Bearer ${userToken('iu-node-admin')}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.triggered)).toBe(true); + }); + it('returns triggered/rateLimited/failed arrays for admin caller', async () => { const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie); expect(res.status).toBe(200); @@ -376,12 +549,125 @@ describe('POST /api/auto-update/execute', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('rejects a role without stack:deploy with 403 PERMISSION_DENIED', async () => { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Cookie', viewerCookie) + .send({ target: 'execute-authz-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects a role without stack:deploy on target="*" even when the node has no stacks', async () => { + // On a fresh test instance the "*" expansion resolves to zero stacks, which + // would otherwise short-circuit into a "no stacks found" 200 before any + // per-stack permission check has anything to iterate over. The wildcard + // case requires global stack:deploy up front specifically to close that gap. const res = await request(app) .post('/api/auto-update/execute') .set('Cookie', viewerCookie) .send({ target: '*' }); expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows a Deployer to execute a single-stack target', async () => { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`) + .send({ target: 'deployer-exec-stack' }); + expect(res.status).toBe(200); + expect(typeof res.body.result).toBe('string'); + }); + + it('denies a Deployer stripped of stack:deploy with 403 PERMISSION_DENIED', async () => { + const { ROLE_PERMISSIONS } = await import('../middleware/permissions'); + const original = ROLE_PERMISSIONS.deployer; + ROLE_PERMISSIONS.deployer = original.filter((p) => p !== 'stack:deploy'); + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-deployer')}`) + .send({ target: 'deployer-exec-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + ROLE_PERMISSIONS.deployer = original; + } + }); + + it('denies the whole bulk request when one target is unauthorized, with no partial execution', async () => { + // Scoped user: global viewer role (no stack:deploy anywhere) plus a + // deployer role assignment scoped to "bulk-allowed" only. Requesting + // ["bulk-allowed", "bulk-denied"] must deny the entire call on the second + // stack and never touch either stack's containers. + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('scopedpass', 1); + const scopedUserId = db.addUser({ username: 'iu-bulk-scoped', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'bulk-allowed', node_id: nodeId }); + + const DockerController = (await import('../services/DockerController')).default; + const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack'); + + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-bulk-scoped')}`) + .send({ targets: ['bulk-allowed', 'bulk-denied'] }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(containersSpy).not.toHaveBeenCalled(); + } finally { + containersSpy.mockRestore(); + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + } + }); + + it('rejects a role without stack:deploy with 403 even when checks are disabled node-wide', async () => { + // Permission must be evaluated before the checks-enabled setting is + // consulted: a disabled node must not let an unauthorized caller through + // to the "disabled; skipped" 200 that a legitimate caller would see. + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0'); + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Cookie', viewerCookie) + .send({ target: 'checks-disabled-authz-stack' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1'); + } + }); + + it('denies target="*" for a scoped-only user even when their grant covers every stack on the node', async () => { + // A user with ONLY a scoped stack:deploy role_assignment (no global + // stack:deploy role) is denied the wildcard outright, even though the + // same grant would pass requireExactStacks if the caller enumerated the + // stack explicitly via targets instead of relying on "*" to expand it. + // This is the brief-sanctioned "deny without global deploy" tradeoff for + // the wildcard case. + makeOnDiskStack('wildcard-scoped-stack'); + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('scopedpass', 1); + const scopedUserId = db.addUser({ username: 'iu-wildcard-scoped', password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'wildcard-scoped-stack', node_id: nodeId }); + + try { + const res = await request(app) + .post('/api/auto-update/execute') + .set('Authorization', `Bearer ${userToken('iu-wildcard-scoped')}`) + .send({ target: '*' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + } finally { + db.deleteRoleAssignmentsByUser(scopedUserId); + db.deleteUser(scopedUserId); + removeOnDiskStack('wildcard-scoped-stack'); + } }); it('serves a community-licensed admin (no paid gate)', async () => { diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index 1b707c1a..3f319d74 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { isValidServiceName } from '../utils/validation'; import { getActiveCapabilities, @@ -28,10 +28,16 @@ const AlertCreateSchema = z.object({ export const alertsRouter = Router(); alertsRouter.get('/', authMiddleware, async (req: Request, res: Response) => { - try { - let stackName = req.query.stackName as string | undefined; - if (Array.isArray(stackName)) stackName = stackName[0] as string; + let stackName = req.query.stackName as string | undefined; + if (Array.isArray(stackName)) stackName = stackName[0] as string; + if (stackName) { + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + } else { + if (!requirePermission(req, res, 'stack:read')) return; + } + + try { const alerts = DatabaseService.getInstance().getStackAlerts(stackName); res.json(alerts); } catch (error) { @@ -41,12 +47,12 @@ alertsRouter.get('/', authMiddleware, async (req: Request, res: Response) => { }); alertsRouter.post('/', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; const parsed = AlertCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', parsed.data.stack_name)) return; const { service_name, ...alertFields } = parsed.data; const serviceName = service_name ?? null; if ( @@ -72,7 +78,6 @@ alertsRouter.post('/', authMiddleware, async (req: Request, res: Response) => { }); alertsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) => { - if (!requireAdmin(req, res)) return; // Reject leading-junk / fractional ids (parseInt("1abc") === 1, parseInt("2.5") === 2). const rawId = String(req.params.id ?? ''); const id = /^\d+$/.test(rawId) ? Number.parseInt(rawId, 10) : NaN; @@ -80,8 +85,15 @@ alertsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response) res.status(400).json({ error: 'Invalid alert id' }); return; } + const db = DatabaseService.getInstance(); + const alert = db.getStackAlert(id); + if (!alert) { + res.status(404).json({ error: 'Alert not found' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', alert.stack_name)) return; try { - DatabaseService.getInstance().deleteStackAlert(id); + db.deleteStackAlert(id); res.json({ success: true }); } catch (error) { console.error('Failed to delete alert:', error); diff --git a/backend/src/routes/autoHeal.ts b/backend/src/routes/autoHeal.ts index edf06442..4320107a 100644 --- a/backend/src/routes/autoHeal.ts +++ b/backend/src/routes/autoHeal.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requirePermission } from '../middleware/permissions'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; @@ -31,6 +31,11 @@ function proxyEntitlementUntil(req: Request): number { autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined; + if (stackName) { + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + } else { + if (!requirePermission(req, res, 'stack:read')) return; + } try { const db = DatabaseService.getInstance(); const policies = db.getAutoHealPolicies(stackName, req.nodeId); @@ -50,12 +55,12 @@ autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): v }); autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const parsed = AutoHealPolicyCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', parsed.data.stack_name)) return; const { stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures } = parsed.data; const now = Date.now(); try { @@ -82,7 +87,6 @@ autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): }); autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body); @@ -94,6 +98,7 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.updateAutoHealPolicy(id, { ...parsed.data, proxy_entitled_until: Math.max(policy.proxy_entitled_until, proxyEntitlementUntil(req)) }); res.json(db.getAutoHealPolicy(id)); } catch (err) { @@ -103,13 +108,13 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon }); autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.deleteAutoHealPolicy(id); res.json({ success: true }); } catch (err) { @@ -126,6 +131,7 @@ autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res: const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } + if (!requirePermission(req, res, 'stack:read', 'stack', policy.stack_name)) return; res.json(db.getAutoHealHistory(id, limit)); } catch (err) { console.error('[AutoHeal] Failed to fetch history:', getErrorMessage(err, 'unknown')); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 7936ffd0..c6ea4af5 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -19,8 +19,7 @@ import { NotificationService } from '../services/NotificationService'; 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 { checkPermission, requirePermission, type PermissionAction } from '../middleware/permissions'; import { buildPolicyGateOptions } from '../helpers/policyGate'; import { FLEET_UPDATE_CACHE_KEY, invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -98,6 +97,40 @@ imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response } }); +// Per-stack manual recheck, distinct from the node-wide /refresh above. Reuses +// the same registry probe ImageUpdateService runs after an applied update. +imageUpdatesRouter.post('/refresh/:stackName', authMiddleware, async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + try { + if (!ImageUpdateService.isChecksEnabled()) { + res.status(409).json({ + enabled: false, + error: 'Image update detection is disabled for this node.', + }); + return; + } + const iu = ImageUpdateService.getInstance(); + if (!iu.tryMarkStackRecheck(req.nodeId, stackName)) { + const remainingMs = iu.getStackRecheckCooldownRemainingMs(req.nodeId, stackName); + const remainingSec = Math.ceil(remainingMs / 1000); + res.status(429).json({ + error: `Per-stack check was started too recently. Please wait ${remainingSec} second${remainingSec !== 1 ? 's' : ''}.`, + }); + return; + } + const result = await iu.recheckStack(req.nodeId, stackName); + res.json(result); + } catch (error) { + console.error('Failed to recheck stack for image updates:', error); + res.status(500).json({ error: 'Failed to recheck stack for image updates' }); + } +}); + imageUpdatesRouter.get('/status', authMiddleware, (req: Request, res: Response): void => { const startedAt = Date.now(); let outcome: 'ok' | 'error' = 'ok'; @@ -210,7 +243,6 @@ imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response) }); imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; try { const result = await CacheService.getInstance().getOrFetch>>( FLEET_UPDATE_CACHE_KEY, @@ -274,7 +306,7 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo }); imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, res: Response): Promise => { - if (!requireAdmin(_req, res)) return; + if (!requirePermission(_req, res, 'node:manage')) return; const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -357,14 +389,30 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, */ export const autoUpdateRouter = Router(); -autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; - try { - // Honor the node-scoped image-update detection opt-out before any work. - if (!ImageUpdateService.isChecksEnabled()) { - res.json({ result: 'Image update detection is disabled for this node; skipped.' }); - return; +/** + * Deny the whole request on the first stack that fails `action`, writing the + * 403 itself. Used to pre-check every resolved target before any auto-update + * work starts, so a denied stack in a bulk request never leaves partial work + * behind. + */ +function requireExactStacks( + req: Request, + res: Response, + action: PermissionAction, + stackNames: Iterable, + nodeId: number, +): boolean { + for (const stackName of stackNames) { + if (!checkPermission(req, action, 'stack', stackName, nodeId)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return false; } + } + return true; +} + +autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise => { + try { const { target, targets } = req.body as { target?: string; targets?: unknown }; let stackNames: string[]; @@ -393,6 +441,12 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp } else if (typeof target === 'string' && target.length > 0) { console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target)}"`); if (target === '*') { + // The wildcard expands to every stack on the node, including a set + // this handler cannot enumerate permission against ahead of time + // when it turns out to be empty. Require global stack:deploy rather + // than a scoped grant, so an unauthorized caller cannot reach the + // "no stacks found" no-op without ever being permission-checked. + if (!requirePermission(req, res, 'stack:deploy')) return; stackNames = await FileSystemService.getInstance(req.nodeId).getStacks(); if (stackNames.length === 0) { res.json({ result: 'No stacks found on node; skipped.' }); @@ -410,6 +464,21 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp return; } + // Pre-check every resolved target before any work starts: a denied stack + // anywhere in the set (including a "*" expansion) fails the whole request + // rather than running some stacks and skipping others. Permission is + // evaluated unconditionally, before the node's checks-enabled setting is + // even consulted, so a disabled node never gives an unauthorized caller + // a free pass. + if (!requireExactStacks(req, res, 'stack:deploy', stackNames, req.nodeId)) return; + + // Honor the node-scoped image-update detection opt-out, now that every + // resolved target has cleared the permission gate above. + if (!ImageUpdateService.isChecksEnabled()) { + res.json({ result: 'Image update detection is disabled for this node; skipped.' }); + return; + } + const docker = DockerController.getInstance(req.nodeId); const imageUpdateService = ImageUpdateService.getInstance(); const atomic = true; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index dad728f6..fb4abfb7 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -3506,6 +3506,10 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM stack_alerts').all() as StackAlert[]; } + public getStackAlert(id: number): StackAlert | undefined { + return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(id) as StackAlert | undefined; + } + public addStackAlert(alert: StackAlert): StackAlert { const stmt = this.db.prepare( 'INSERT INTO stack_alerts (stack_name, service_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 25d1f5a0..50c8d427 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -430,6 +430,12 @@ export class ImageUpdateService { private isRunning = false; private checkStartedAt = 0; private lastManualRefreshAt = 0; + // Per-stack recheck cooldown (key: `${nodeId}:${stackName}`). Enforces the + // same MANUAL_COOLDOWN_MS as the node-wide triggerManualRefresh, and also + // acts as an in-flight gate: the mark writes before the first await, so a + // second synchronous check-and-mark on the same event-loop tick sees the + // first entry and is denied. + private perStackRecheckAt = new Map(); private lastCheckedAt: number | null = null; // when the last scan body started private nextCheckAt: number | null = null; // Initialized at declaration so getStatus() never reports NaN before start() @@ -693,6 +699,41 @@ export class ImageUpdateService { return Math.max(0, this.lastManualRefreshAt + ImageUpdateService.MANUAL_COOLDOWN_MS - Date.now()); } + /** + * Per-stack rate gate for explicit rechecks (idiomatic API calls and the + * sidebar "Check updates" action). Reuses the same MANUAL_COOLDOWN_MS as the + * node-wide manual trigger so both surfaces share one cooldown policy without + * a new knob. + * + * Returns true when the recheck is allowed and atomically marks in-flight; + * returns false when a recheck for this (nodeId, stackName) was started + * within the cooldown window (including one that is still in-flight, whose + * mark was written synchronously on the previous event-loop tick before the + * first await). + */ + public tryMarkStackRecheck(nodeId: number, stackName: string): boolean { + const key = `${nodeId}:${stackName}`; + const now = Date.now(); + const lastAt = this.perStackRecheckAt.get(key) ?? 0; + if (now - lastAt < ImageUpdateService.MANUAL_COOLDOWN_MS) { + return false; + } + this.perStackRecheckAt.set(key, now); + return true; + } + + /** Milliseconds left on the per-stack recheck cooldown; 0 when allowed. */ + public getStackRecheckCooldownRemainingMs(nodeId: number, stackName: string): number { + const key = `${nodeId}:${stackName}`; + const lastAt = this.perStackRecheckAt.get(key) ?? 0; + return Math.max(0, lastAt + ImageUpdateService.MANUAL_COOLDOWN_MS - Date.now()); + } + + /** Clear every per-stack recheck cooldown (test-only). */ + public resetStackRecheckCooldowns(): void { + this.perStackRecheckAt.clear(); + } + public getStatus(): ImageUpdateStatus { const enabled = ImageUpdateService.isChecksEnabled(); let sidebarIndicators = false; diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 3eee2c61..ed88e09c 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -20,7 +20,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 | +| **Deployer** | Deploy, restart, stop, and start stacks, and check individual stacks for image updates. Cannot edit compose files, create or delete stacks, view nodes, or manage alert and auto-heal rules | 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 | @@ -31,8 +31,8 @@ Each row is one of the permission keys the backend checks. The matrix below is t | Permission | Admin | Node Admin | Deployer | Auditor | Viewer | |------------|:-----:|:----------:|:--------:|:-------:|:------:| | View stacks, logs, stats (`stack:read`) | Yes | Yes | Yes | Yes | Yes | -| Deploy, restart, stop, start, take down (`stack:deploy`) | Yes | Yes | Yes | No | No | -| Edit compose and `.env` files (`stack:edit`) | Yes | Yes | No | No | No | +| Deploy, restart, stop, start, take down, check a stack for image updates (`stack:deploy`) | Yes | Yes | Yes | No | No | +| Edit compose and `.env` files, manage alert rules and auto-heal policies (`stack:edit`) | Yes | Yes | No | No | No | | Create stacks (`stack:create`) | Yes | Yes | No | No | No | | Delete stacks (`stack:delete`) | Yes | Yes | No | No | No | | View nodes (`node:read`) | Yes | Yes | No | Yes | Yes | diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index e02a50fd..dfb5beb6 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -20,6 +20,7 @@ import { isVerificationOnlyPreview, } from '@/lib/updatePreviewActionability'; import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, Kicker } from '@/components/mobile/mobile-ui'; import { ImageSourceMenu } from './ImageSourceMenu'; @@ -469,6 +470,7 @@ function ReadinessHero({ nodeCount, refreshing, onRefresh, + canRefresh, unresolvedChecks = false, detectionDisabled = false, }: { @@ -477,6 +479,7 @@ function ReadinessHero({ nodeCount: number; refreshing: boolean; onRefresh: () => void; + canRefresh: boolean; unresolvedChecks?: boolean; detectionDisabled?: boolean; }) { @@ -523,21 +526,23 @@ function ReadinessHero({ )} - + {canRefresh && ( + + )} @@ -791,6 +796,8 @@ interface AutoUpdateReadinessProps { function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) { const isMobile = useIsMobile(); const { runWithLog } = useDeployFeedback(); + const { can } = useAuth(); + const canRefreshFleet = can('node:manage'); const { nodes, nodeMeta, refreshNodeMeta } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); @@ -1296,10 +1303,12 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) />
- + {canRefreshFleet && ( + + )}
@@ -1351,6 +1360,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) nodeCount={groups.length} refreshing={refreshing} onRefresh={handleRefresh} + canRefresh={canRefreshFleet} unresolvedChecks={checkFailures.length > 0} detectionDisabled={cadence?.enabled === false} /> diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts index 5bd311df..1ee613f8 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -10,10 +10,13 @@ vi.mock('@/context/NodeContext', () => ({ // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. +type CanFn = (action: string, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; + function makeOptions( activeNode: Node | null, stackPorts: Record, stackStatuses: Record = { 'web.yml': 'running' }, + can: CanFn = () => true, ) { const stackListState = { stackStatuses, @@ -42,10 +45,16 @@ function makeOptions( stackActions, activeNode, isAdmin: true, - can: () => true, + can, } as unknown as Parameters[0]; } +// Reach past the `unknown` cast makeOptions returns to assert on the inner +// stackActions mocks it built. +function stackActionsOf(options: Parameters[0]) { + return (options as unknown as { stackActions: { checkUpdatesForStack: ReturnType } }).stackActions; +} + describe('useSidebarContextMenu canOpenApp', () => { it('is true for a local node with a published port', () => { const { result } = renderHook(() => @@ -89,3 +98,36 @@ describe('useSidebarContextMenu stackStatus', () => { expect(missing.result.current('web.yml').stackStatus).toBe('unknown'); }); }); + +describe('useSidebarContextMenu checkUpdates', () => { + it('calls checkUpdatesForStack with the stack name (not the .yml file)', () => { + const options = makeOptions({ id: 1, type: 'local' } as Node, { 'web.yml': 8989 }); + const { result } = renderHook(() => useSidebarContextMenu(options)); + result.current('web.yml').checkUpdates(); + expect(stackActionsOf(options).checkUpdatesForStack).toHaveBeenCalledWith('web'); + }); +}); + +describe('useSidebarContextMenu canViewMonitor / canCheckUpdates wiring', () => { + it('derives canViewMonitor from stack:read and canCheckUpdates from stack:deploy, both scoped to the stack and active node', () => { + const can = vi.fn((action) => action === 'stack:read'); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(true); + expect(ctx.canCheckUpdates).toBe(false); + expect(can).toHaveBeenCalledWith('stack:read', 'stack', 'web', 7); + expect(can).toHaveBeenCalledWith('stack:deploy', 'stack', 'web', 7); + }); + + it('denies both when the permission check fails closed', () => { + const can = vi.fn(() => false); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(false); + expect(ctx.canCheckUpdates).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 3cefcb23..2628237c 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -20,7 +20,6 @@ import type { useViewNavigationState } from './useViewNavigationState'; import type { Node } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext'; import type { PermissionAction } from '@/context/AuthContext'; -import { canManageNode } from '@/lib/canManageNode'; type StackListState = ReturnType; type NavState = ReturnType; @@ -65,6 +64,7 @@ export function useSidebarContextMenu({ canDelete: can('stack:delete', 'stack', sName, nodeId), canDeploy: can('stack:deploy', 'stack', sName, nodeId), canEditLabels: can('stack:edit', 'stack', sName, nodeId), + canViewMonitor: can('stack:read', 'stack', sName, nodeId), // POST /api/labels (the inline "New label" entry) is guarded by the // unscoped requirePermission('stack:edit'); a user with only per-stack // scoped edit can toggle existing labels but cannot create new ones. @@ -75,8 +75,8 @@ export function useSidebarContextMenu({ menuVisibility: stackActions.getStackMenuVisibility(file), openAlertSheet: () => overlayState.openAlertSheet(file), openAutoHeal: () => overlayState.openAutoHeal(file), - canCheckUpdates: canManageNode(can, nodeId), - checkUpdates: () => stackActions.checkUpdatesForStack(), + canCheckUpdates: can('stack:deploy', 'stack', sName, nodeId), + checkUpdates: () => stackActions.checkUpdatesForStack(sName), openStackApp: () => stackActions.openStackApp(file), deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'), diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 0fdeabcc..f00aa0be 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/api', () => ({ }), })); vi.mock('@/components/ui/toast-store', () => ({ - toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(() => 'loading-id'), dismiss: vi.fn() }, })); import { apiFetch } from '@/lib/api'; @@ -249,6 +249,87 @@ describe('useStackActions.handleSaveAndDeploy', () => { }); }); +describe('useStackActions.checkUpdatesForStack', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiFetch).mockReset(); + }); + + it('hits the per-stack refresh endpoint and shows success when the stack is cleared', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response(JSON.stringify({ outcome: 'cleared', warning: null }), { status: 200 }), + ); + const { result, stackListState } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(apiFetch).toHaveBeenCalledWith('/image-updates/refresh/web', { method: 'POST' }); + expect(stackListState.fetchImageUpdates).toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.success).toHaveBeenCalledWith('Image update check complete.'); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it('shows the warning via toast.info instead of success when verification did not cleanly complete', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ outcome: 'verification_failed', warning: 'Could not verify the update.' }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('Could not verify the update.'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('replaces the generic post-update warning copy for an incomplete verification too', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'verification_incomplete', + warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.info).toHaveBeenCalledWith('Could not fully verify update status for web.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + }); + + it('uses stack-scoped copy instead of the backend post-update warning when an update is still present', async () => { + // The backend reuses its post-update reconciliation result for this + // manual pre-update check, so its "still_present" warning text ("The + // update command completed...") does not apply here; the frontend must + // not forward it verbatim. + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'still_present', + warning: 'The update command completed, but Sencho still detects an available image update.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('web still has an update available.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('shows a loading toast immediately and dismisses it on error', async () => { + vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ error: 'nope' }), { status: 500 })); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.loading).toHaveBeenCalledWith('Checking web for image updates...'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.error).toHaveBeenCalledWith('nope'); + }); +}); + describe('useStackActions node binding', () => { const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index f945c407..088dfad7 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -79,6 +79,16 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = { const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +// Mirrors ImageUpdateService's UPDATE_STILL_PRESENT_WARNING / UPDATE_VERIFICATION_INCOMPLETE_WARNING: +// that service's warning copy assumes an update was just applied, but +// checkUpdatesForStack runs before any update, so these two generic messages +// are replaced with accurate pre-update copy. A stack-specific reason (e.g. a +// compose render failure) is still forwarded as-is. +const GENERIC_POST_UPDATE_WARNINGS: ReadonlySet = new Set([ + 'The update command completed, but Sencho still detects an available image update.', + 'The update command completed, but Sencho could not fully verify whether an image update remains.', +]); + const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => { @@ -416,7 +426,6 @@ export function useStackActions(options: UseStackActionsOptions) { const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); - const checkUpdatesIntervalRef = useRef | null>(null); // True from a deploy click through the async pre-deploy advisory phase until // the deploy starts or is cancelled, so a double-click cannot start two deploys. const deployPendingRef = useRef(false); @@ -471,9 +480,6 @@ export function useStackActions(options: UseStackActionsOptions) { useEffect(() => { return () => { - if (checkUpdatesIntervalRef.current !== null) { - clearInterval(checkUpdatesIntervalRef.current); - } loadFileAbortRef.current?.abort(); containersFetchGenRef.current += 1; }; @@ -2244,38 +2250,32 @@ export function useStackActions(options: UseStackActionsOptions) { } }; - const checkUpdatesForStack = async () => { + const checkUpdatesForStack = async (stackName: string) => { + const loadingId = toast.loading(`Checking ${stackName} for image updates...`); try { - const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + const res = await apiFetch(`/image-updates/refresh/${encodeURIComponent(stackName)}`, { method: 'POST' }); if (res.ok) { - toast.success('Checking for image updates...'); - let elapsed = 0; - const poll = setInterval(async () => { - elapsed += 2000; - try { - const statusRes = await apiFetch('/image-updates/status'); - if (statusRes.ok) { - const { checking } = await statusRes.json(); - if (!checking || elapsed >= 60000) { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - if (!checking) toast.success('Image update check complete.'); - } - } - } catch { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - } - }, 2000); - checkUpdatesIntervalRef.current = poll; + const data = await res.json().catch(() => ({})) as { outcome?: unknown; warning?: unknown }; + await stackListState.fetchImageUpdates(); + const warning = typeof data.warning === 'string' ? data.warning : undefined; + if (data.outcome === 'still_present') { + toast.info(`${stackName} still has an update available.`); + } else if (warning && GENERIC_POST_UPDATE_WARNINGS.has(warning)) { + toast.info(`Could not fully verify update status for ${stackName}.`); + } else if (warning) { + toast.info(warning); + } else { + toast.success('Image update check complete.'); + } } else { const data = await res.json().catch(() => ({})); toast.error(data.error || 'Failed to check for updates'); } - } catch { + } catch (error) { + console.error(`Failed to check updates for stack ${stackName}:`, error); toast.error('Failed to check for updates'); + } finally { + toast.dismiss(loadingId); } }; diff --git a/frontend/src/components/StackAlertSheet.test.tsx b/frontend/src/components/StackAlertSheet.test.tsx index a598be3d..81a4a6fd 100644 --- a/frontend/src/components/StackAlertSheet.test.tsx +++ b/frontend/src/components/StackAlertSheet.test.tsx @@ -34,8 +34,9 @@ vi.mock('@/context/NodeContext', () => ({ hasCapability: (cap: string) => nodeState.activeNodeMeta?.capabilities.includes(cap) === true, }), })); +const useAuthMock = vi.fn(); vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true }), + useAuth: () => useAuthMock(), })); import { apiFetch } from '@/lib/api'; @@ -62,6 +63,8 @@ beforeEach(() => { mockedFetch.mockReset(); vi.mocked(toast.success).mockReset(); vi.mocked(toast.error).mockReset(); + useAuthMock.mockReset(); + useAuthMock.mockReturnValue({ isAdmin: true, can: () => true }); }); function mockHappyPath(services: string[] = ['api', 'database'], alerts: unknown[] = []) { @@ -306,3 +309,72 @@ describe('StackAlertSheet Alerts tab', () => { }); }); }); + +describe('StackAlertSheet permission gating (stack:edit deny path)', () => { + it('AlertsTab hides Add new rule and the delete-alert control when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockHappyPath(['api'], [{ + id: 1, + stack_name: 'my-stack', + service_name: 'api', + metric: 'cpu_percent', + operator: '>', + threshold: 80, + duration_mins: 5, + cooldown_mins: 60, + }]); + + render( {}} stackName="my-stack" />); + + // Reads stay visible: the rule itself still renders for a stack:read-only caller. + await waitFor(() => expect(screen.getByText('api')).toBeInTheDocument()); + expect(screen.queryByText('Add Rule')).toBeNull(); + expect(screen.queryByText('Add new rule')).toBeNull(); + expect(screen.queryByLabelText('Delete alert')).toBeNull(); + }); + + it('AutoHealTab hides Add new policy and PolicyRow edit affordances when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockedFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auto-heal/policies')) { + return jsonRes([{ + id: 1, + stack_name: 'my-stack', + service_name: null, + unhealthy_duration_mins: 5, + cooldown_mins: 5, + max_restarts_per_hour: 3, + auto_disable_after_failures: 5, + enabled: 1, + consecutive_failures: 0, + }]); + } + if (url.includes('/services')) return jsonRes(['api', 'database']); + return jsonRes(null, false); + }); + + render( + {}} + stackName="my-stack" + initialTab="auto-heal" + />, + ); + + // Reads stay visible: the policy row itself still renders. + await waitFor(() => expect(screen.getByText('All services')).toBeInTheDocument()); + expect(screen.queryByText('Add Policy')).toBeNull(); + expect(screen.queryByLabelText(/toggle policy for/i)).toBeNull(); + expect(screen.queryByLabelText('Delete policy')).toBeNull(); + // History is not edit-gated and should remain available either way. + expect(screen.getByLabelText('Toggle history')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index bd1766d2..69f8c036 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -184,8 +184,9 @@ export function StackAlertSheet({ } function AlertsTab({ stackName, initialService }: { stackName: string; initialService?: string }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); const { activeNode, activeNodeMeta } = useNodes(); + const canEditAlerts = can('stack:edit', 'stack', stackName, activeNode?.id); const isRemote = activeNode?.type === 'remote'; const canScopeService = activeNodeMeta?.capabilities.includes(SERVICE_SCOPED_STACK_ALERT_CAPABILITY) === true; @@ -443,13 +444,14 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe {' '}• Trigger after {alert.duration_mins}m • Cooldown {alert.cooldown_mins}m
- {isAdmin && ( + {canEditAlerts && ( @@ -460,7 +462,7 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe )} - {isAdmin && ( + {canEditAlerts && (
{canScopeService && ( @@ -595,7 +597,9 @@ function AutoHealTab({ open: boolean; initialService?: string; }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); + const { activeNode } = useNodes(); + const canEditAutoHeal = can('stack:edit', 'stack', stackName, activeNode?.id); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -739,14 +743,14 @@ function AutoHealTab({ onToggle={handleToggle} deleting={deleting} saving={saving} - isAdmin={isAdmin} + canEdit={canEditAutoHeal} /> ))}
)}
- {isAdmin && ( + {canEditAutoHeal && (
@@ -835,10 +839,11 @@ interface PolicyRowProps { onToggle: (id: number, enabled: boolean) => void; deleting: boolean; saving: boolean; - isAdmin: boolean; + /** True when the caller may toggle/delete this policy (stack:edit). */ + canEdit: boolean; } -function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: PolicyRowProps) { +function PolicyRow({ policy, onDelete, onToggle, deleting, saving, canEdit }: PolicyRowProps) { const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); @@ -886,7 +891,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )}
- {isAdmin && ( + {canEdit && ( policy.id != null && onToggle(policy.id, checked)} @@ -910,7 +915,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )} - {isAdmin && ( + {canEdit && (