diff --git a/backend/src/__tests__/scheduled-tasks-rbac.test.ts b/backend/src/__tests__/scheduled-tasks-rbac.test.ts new file mode 100644 index 00000000..2cc2a91f --- /dev/null +++ b/backend/src/__tests__/scheduled-tasks-rbac.test.ts @@ -0,0 +1,456 @@ +/** + * RBAC tests for /api/scheduled-tasks. Verifies that target-aware permission + * checks replace the blanket requireAdmin gate: scoped deployers can create + * stack-lifecycle schedules for their stacks, node admins can create node-wide + * schedules, viewers/auditors get 403 on mutations, and listing is filtered. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +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; +let deployerCookie: string; +let viewerCookie: string; +let auditorCookie: string; +let tierSpy: ReturnType; + +/** + * Creates a user whose ONLY source of the given permission is a scoped + * role assignment. The global role is set to 'viewer' so the scoped + * grant is the sole path for authorization beyond read-only access. + */ +async function createScopedUser( + app: import('express').Express, + db: ReturnType, + username: string, + assignmentRole: 'deployer' | 'node-admin', + resourceType: 'stack' | 'node', + resourceId: string, + nodeId?: number, +): Promise { + const hash = await bcrypt.hash('testpass', 1); + const userId = db.addUser({ username, password_hash: hash, role: 'viewer' }); + db.addRoleAssignment({ user_id: userId, role: assignmentRole, resource_type: resourceType, resource_id: resourceId, node_id: nodeId ?? null }); + const res = await request(app).post('/api/auth/login').send({ username, password: 'testpass' }); + const cookies = res.headers['set-cookie'] as string | string[]; + return Array.isArray(cookies) ? cookies[0] : cookies; +} + +let scopedDeployerCookie: string; +let scopedNodeAdminCookie: string; +let secondStackDeployerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + const db = DatabaseService.getInstance(); + + const { LicenseService } = await import('../services/LicenseService'); + tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + // Global roles + for (const [role, pw] of [['deployer', 'dp'], ['viewer', 'vwp'], ['auditor', 'aud']] as const) { + const hash = await bcrypt.hash(pw, 1); + db.addUser({ username: `sched-${role}`, password_hash: hash, role }); + const res = await request(app).post('/api/auth/login').send({ username: `sched-${role}`, password: pw }); + const cookies = res.headers['set-cookie'] as string | string[]; + const c = Array.isArray(cookies) ? cookies[0] : cookies; + if (role === 'deployer') deployerCookie = c; + else if (role === 'viewer') viewerCookie = c; + else auditorCookie = c; + } + + // Insert a local node for stack-target fixtures + for (const [nodeName, nodeType] of [['local-test', 'local'], ['remote-test', 'remote']] as const) { + const existing = db.getDb().prepare('SELECT id FROM nodes WHERE name = ?').get(nodeName) as { id: number } | undefined; + if (!existing) { + db.getDb().prepare( + `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at) + VALUES (?, ?, 'proxy', '/tmp/compose', 0, 'online', ?)`, + ).run(nodeName, nodeType, Date.now()); + } + } + + // Scoped deployer: stack:deploy on stack "web" at node 1 + scopedDeployerCookie = await createScopedUser(app, db, 'scoped-deploy', 'deployer', 'stack', 'web', 1); + // Scoped node-admin: node:manage on node 1 + scopedNodeAdminCookie = await createScopedUser(app, db, 'scoped-nodeadm', 'node-admin', 'node', '1'); + // Second scoped deployer: stack:deploy on stack "api" at node 1 + secondStackDeployerCookie = await createScopedUser(app, db, 'scoped-deploy-2', 'deployer', 'stack', 'api', 1); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM scheduled_tasks').run(); + tierSpy.mockReturnValue('paid'); +}); + +const stackRestartPayload = { + name: 'nightly-restart', target_type: 'stack', target_id: 'web', + node_id: 1, action: 'restart', cron_expression: '0 3 * * *', enabled: true, +}; + +const nodeScanPayload = { + name: 'nightly-scan', target_type: 'system', target_id: null, + node_id: 1, action: 'scan', cron_expression: '0 4 * * *', enabled: true, +}; + +const prunePayload = { + name: 'weekly-prune', target_type: 'system', target_id: null, + node_id: 1, action: 'prune', cron_expression: '0 5 * * 0', enabled: true, +}; + +const fleetUpdatePayload = { + name: 'fleet-update', target_type: 'fleet', target_id: null, + node_id: 1, action: 'update', cron_expression: '0 6 * * *', enabled: true, +}; + +// ── Create ───────────────────────────────────────────────────────────────── + +describe('POST /api/scheduled-tasks (RBAC)', () => { + it('allows global deployer (stack:deploy) to create stack restart', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + }); + + it('allows global deployer to create node scan (node:manage)', async () => { + // Global deployer has stack:read + stack:deploy only — no node:manage. + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(nodeScanPayload); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows global deployer to create fleet-wide update (node:manage)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(fleetUpdatePayload); + expect(res.status).toBe(403); + }); + + it('rejects global deployer creating prune (system:settings)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(prunePayload); + expect(res.status).toBe(403); + }); + + it('allows admin to create prune', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(prunePayload); + expect(res.status).toBe(201); + }); + + it('allows scoped deployer on their own stack', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + }); + + it('rejects scoped deployer on a different stack', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send({ + ...stackRestartPayload, target_id: 'api', + }); + expect(res.status).toBe(403); + }); + + it('rejects scoped deployer creating node scan', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie).send(nodeScanPayload); + expect(res.status).toBe(403); + }); + + it('allows scoped node-admin to create node scan', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedNodeAdminCookie).send(nodeScanPayload); + expect(res.status).toBe(201); + }); + + it('rejects scoped node-admin creating prune (unscoped, admin-only)', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', scopedNodeAdminCookie).send(prunePayload); + expect(res.status).toBe(403); + }); + + it('rejects viewer creating any task', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', viewerCookie).send(stackRestartPayload); + expect(res.status).toBe(403); + }); + + it('rejects auditor creating any task', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', auditorCookie).send(stackRestartPayload); + expect(res.status).toBe(403); + }); + + it('records creator_user_id from the authenticated user', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', deployerCookie).send(stackRestartPayload); + expect(res.status).toBe(201); + const task = DatabaseService.getInstance().getScheduledTask(res.body.id); + expect(task).toBeDefined(); + expect(task!.creator_user_id).not.toBeNull(); + expect(task!.created_by).toBe('sched-deployer'); + }); +}); + +// ── List filtering ───────────────────────────────────────────────────────── + +describe('GET /api/scheduled-tasks (RBAC listing filter)', () => { + let db: ReturnType; + + beforeEach(() => { + db = DatabaseService.getInstance(); + // Create a mix of tasks + db.createScheduledTask({ + name: 'web-restart', target_type: 'stack', target_id: 'web', node_id: 1, + action: 'restart', cron_expression: '0 3 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + db.createScheduledTask({ + name: 'api-restart', target_type: 'stack', target_id: 'api', node_id: 1, + action: 'restart', cron_expression: '0 4 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + db.createScheduledTask({ + name: 'node-scan', target_type: 'system', target_id: null, node_id: 1, + action: 'scan', cron_expression: '0 5 * * *', enabled: 1, + created_by: 'admin', creator_user_id: 1, created_at: 0, updated_at: 0, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + selector_type: null, selector_value: null, delete_after_run: 0, run_at: null, + }); + }); + + it('admin sees all tasks', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + }); + + it('scoped deployer sees only their own stack tasks', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].target_id).toBe('web'); + }); + + it('global deployer sees all stack and node tasks but not prune', async () => { + // Global deployer: stack:read + stack:deploy. Can see stack tasks but not + // scan (node:manage) or prune (system:settings). Fleet update without a + // specific node_id is unscoped node:manage — also 403. + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', deployerCookie); + expect(res.status).toBe(200); + // Should see only the two stack restart tasks + expect(res.body).toHaveLength(2); + expect(res.body.every((t: any) => t.target_type === 'stack')).toBe(true); + }); + + it('viewer sees empty list', async () => { + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +// ── By-id / runs / export ────────────────────────────────────────────────── + +describe('GET /:id, /:id/runs, /:id/runs/export (RBAC)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('test-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can GET /:id for their own stack', async () => { + const res = await request(app).get(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + }); + + it('scoped deployer gets 404 for a different stack task', async () => { + // Create a task they don't own + const db = DatabaseService.getInstance(); + const other = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('api-task', 'stack', 'api', 1, 'restart', '0 4 * * *', 1, 'admin', 1, 0, 0) + `).run(); + const res = await request(app).get(`/api/scheduled-tasks/${other.lastInsertRowid}`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(404); + }); +}); + +// ── Update (two-phase) ───────────────────────────────────────────────────── + +describe('PUT /:id (RBAC two-phase)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('web-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can update their own stack task', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ name: 'renamed' }); + expect(res.status).toBe(200); + }); + + it('scoped deployer gets 403 when trying to retarget to a different stack', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ target_id: 'api' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('scoped deployer gets 403 when trying to flip restart -> prune', async () => { + // prune requires target_type: system, so include it to reach the + // permission check rather than hitting structural validation first. + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', scopedDeployerCookie).send({ action: 'prune', target_type: 'system', target_id: null }); + expect(res.status).toBe(403); + }); + + it('second deployer gets 404 editing a task they do not own', async () => { + const res = await request(app).put(`/api/scheduled-tasks/${taskId}`).set('Cookie', secondStackDeployerCookie).send({ name: 'stolen' }); + expect(res.status).toBe(404); + }); +}); + +// ── Toggle / Run-now ─────────────────────────────────────────────────────── + +describe('PATCH /:id/toggle and POST /:id/run (RBAC)', () => { + let taskId: number; + + beforeEach(() => { + const db = DatabaseService.getInstance(); + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('web-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', 1, 0, 0) + `).run(); + taskId = res.lastInsertRowid as number; + }); + + it('scoped deployer can toggle their stack task', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', scopedDeployerCookie); + expect(res.status).toBe(200); + }); + + it('scoped deployer can run-now their stack task', async () => { + const res = await request(app).post(`/api/scheduled-tasks/${taskId}/run`).set('Cookie', scopedDeployerCookie); + // 409 (already running) is also acceptable; 202 is the success case + // 403 means the permission check rejected + expect(res.status).not.toBe(403); + }); + + it('scoped deployer gets 404 on toggle of other stack task', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', secondStackDeployerCookie); + expect(res.status).toBe(404); + }); + + it('viewer gets 404 on toggle', async () => { + const res = await request(app).patch(`/api/scheduled-tasks/${taskId}/toggle`).set('Cookie', viewerCookie); + expect(res.status).toBe(404); + }); +}); + +// ── Execution-time revalidation ──────────────────────────────────────────── + +describe('Scheduler revalidation', () => { + it('rejects a task whose creator was deleted', async () => { + // Verify that executeTask auto-disables the task when the creator no longer exists: + const { SchedulerService } = await import('../services/SchedulerService'); + const db = DatabaseService.getInstance(); + + // Create a task with a non-existent creator_user_id + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('orphan-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'deleted-user', 99999, 0, 0) + `).run(); + const task = db.getScheduledTask(res.lastInsertRowid as number); + expect(task).toBeDefined(); + + // executeTask catches TaskAuthorizationError internally (auto-disables the + // task and records the error), then returns normally rather than re-throwing. + await (SchedulerService.getInstance() as any).executeTask(task!, 'scheduler'); + const updated = db.getScheduledTask(task!.id); + expect(updated!.enabled).toBe(0); + expect(updated!.last_error).toContain('creator account no longer exists'); + }); + + it('legacy task with NULL creator_user_id executes without revalidation', async () => { + const { SchedulerService } = await import('../services/SchedulerService'); + const db = DatabaseService.getInstance(); + + // A legacy task with NULL creator_user_id + const res = db.getDb().prepare(` + INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at) + VALUES ('legacy-task', 'stack', 'web', 1, 'restart', '0 3 * * *', 1, 'admin', NULL, 0, 0) + `).run(); + const task = db.getScheduledTask(res.lastInsertRowid as number); + + // Should not throw TaskAuthorizationError. Any other error (e.g., Docker + // not available) means the revalidation was skipped correctly. + let threwAuthError = false; + try { + await (SchedulerService.getInstance() as any).executeTask(task!, 'scheduler'); + } catch (e: unknown) { + const { TaskAuthorizationError } = await import('../services/SchedulerService'); + threwAuthError = e instanceof TaskAuthorizationError; + } + expect(threwAuthError).toBe(false); + }); +}); + +// ── Registry lockstep ────────────────────────────────────────────────────── + +describe('Action registry lockstep', () => { + it('every BACKEND_SCHEDULED_ACTIONS entry has a valid permission', async () => { + const { BACKEND_SCHEDULED_ACTIONS } = await import('../services/scheduledActionRegistry'); + const { ALL_PERMISSION_ACTIONS } = await import('../middleware/permissions'); + for (const def of BACKEND_SCHEDULED_ACTIONS) { + expect(ALL_PERMISSION_ACTIONS).toContain(def.permission); + } + }); + + it('resolveTaskPermissionScope covers every (action x target_type) pair', async () => { + const { BACKEND_SCHEDULED_ACTIONS, resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + for (const def of BACKEND_SCHEDULED_ACTIONS) { + for (const tt of def.targetTypes) { + const scope = resolveTaskPermissionScope(def.id, tt, 'test-stack', 1, null); + expect(scope.action).toBeDefined(); + expect(scope.action).not.toBeNull(); + } + } + }); + + it('prune resolves unscoped regardless of node_id', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('prune', 'system', null, 1, null); + expect(scope.resourceType).toBeUndefined(); + expect(scope.action).toBe('system:settings'); + }); + + it('snapshot resolves unscoped', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('snapshot', 'fleet', null, null, null); + expect(scope.resourceType).toBeUndefined(); + expect(scope.action).toBe('node:manage'); + }); + + it('scan resolves node-scoped', async () => { + const { resolveTaskPermissionScope } = await import('../services/scheduledActionRegistry'); + const scope = resolveTaskPermissionScope('scan', 'system', null, 1, null); + expect(scope.resourceType).toBe('node'); + expect(scope.resourceId).toBe('1'); + expect(scope.action).toBe('node:manage'); + }); +}); diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 24e92161..aff7d149 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -47,9 +47,11 @@ describe('GET /api/scheduled-tasks', () => { expect(res.status).toBe(401); }); - it('rejects non-admin users with 403', async () => { + it('returns a permission-filtered list for non-admin users (empty for viewers)', async () => { const res = await request(app).get('/api/scheduled-tasks').set('Cookie', viewerCookie); - expect(res.status).toBe(403); + // Viewers have no scheduled-action permission; they see an empty list, not a 403. + expect(res.status).toBe(200); + expect(res.body).toEqual([]); }); it('returns an empty array when no tasks exist', async () => { diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 17541bbf..090ab55e 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -1867,6 +1867,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial, +): boolean { + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + return checkPermission(req, scope.action, scope.resourceType, scope.resourceId, scope.resourceNodeId); +} + +/** + * Require permission for a task. Sends 403 if denied; callers must `return;` on false. + */ +function requireTaskPermission( + req: Request, + res: Response, + task: Pick, +): boolean { + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + return requirePermission(req, res, scope.action, scope.resourceType, scope.resourceId, scope.resourceNodeId); +} + +/** + * Require permission to access an existing task. Returns 404 (not 403) when + * denied, so an unauthorized caller cannot distinguish "task does not exist" + * from "task exists but you are not authorized." Used on by-ID endpoints + * where the task's existence has already been confirmed. + */ +function requireTaskExistsPermission( + req: Request, + res: Response, + task: Pick, +): boolean { + if (checkTaskPermission(req, task)) return true; + res.status(404).json({ error: 'Scheduled task not found' }); + return false; +} + export const scheduledTasksRouter = Router(); scheduledTasksRouter.get('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { let tasks = DatabaseService.getInstance().getScheduledTasks(); - // The Scheduled Operations view manages every task type, so it lists all of - // them. `action` / `exclude_action` exist for the read-only consumers that + + // Permission-filter the full list so a scoped deployer sees only tasks + // targeting their authorized resources. Admin sees every task (checkTaskPermission + // always returns true for admin via checkPermission's admin bypass). + tasks = tasks.filter(t => checkTaskPermission(req, t)); + + // `action` / `exclude_action` exist for the read-only consumers that // want a slice: the Auto-Update readiness card and the sidebar next-run // indicator both request `?action=update`. const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; @@ -288,7 +346,6 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { }); scheduledTasksRouter.post('/', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const { name, target_type, target_id, node_id, action, cron_expression, enabled, @@ -327,19 +384,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const runAtErr = validateRunAt(run_at); if (runAtErr) { res.status(400).json({ error: runAtErr }); return; } - const scheduler = SchedulerService.getInstance(); - const now = Date.now(); - // Persist the one-shot's pinned instant in its own column so it survives a - // disabled state and edit (the yearless cron cannot reconstruct the year). - // next_run_at is the cron-derived run unless a run_at pins it, and is null - // while disabled; the pinned run_at is retained regardless so enabling later - // restores the exact instant. - const pinnedRunAt = typeof run_at === 'number' ? run_at : null; - const nextRun = (enabled === false) - ? null - : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); - const normalizedTargetId = - target_type === 'stack' || target_type === 'container' ? target_id : null; const labelSelector = usesStackLabelSelector(action, target_type, selector_type); const normalizedNodeId = labelSelector ? (node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id)) @@ -347,6 +391,24 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (labelSelector && node_id != null && node_id !== '' && normalizedNodeId === null) { res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return; } + const normalizedTargetId = + target_type === 'stack' || target_type === 'container' ? target_id : null; + + // Permission check on the resolved action+target scope. + if (!requireTaskPermission(req, res, { + action, + target_type, + target_id: normalizedTargetId, + node_id: normalizedNodeId, + selector_type: labelSelector ? STACK_LABEL_SELECTOR : null, + })) return; + + const scheduler = SchedulerService.getInstance(); + const now = Date.now(); + const pinnedRunAt = typeof run_at === 'number' ? run_at : null; + const nextRun = (enabled === false) + ? null + : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); const selectors = normalizeSelectorFields(action, target_type, selector_type, selector_value); const id = DatabaseService.getInstance().createScheduledTask({ @@ -358,6 +420,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { cron_expression, enabled: enabled !== false ? 1 : 0, created_by: req.user?.username || 'admin', + creator_user_id: req.user?.userId ?? null, created_at: now, updated_at: now, last_run_at: null, @@ -384,12 +447,12 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { }); scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const task = DatabaseService.getInstance().getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, task)) return; res.json(task); } catch (error) { console.error('[ScheduledTasks] Get error:', error); @@ -398,7 +461,6 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { }); scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -407,6 +469,14 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + // Two-phase check: (1) the caller must be authorized for the existing task + // (prevents task take-over; returns 404 so task ID existence is not + // disclosed), and (2) the merged target must also be authorized (prevents + // retargeting escalation, like flipping restart→prune; returns 403 since + // this is a permission denial on the requested change, not an ownership + // check). + if (!requireTaskExistsPermission(req, res, existing)) return; + const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, selector_type, selector_value, @@ -529,6 +599,15 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { updates.next_run_at = null; } + // Second phase: the caller must have permission for the merged scope. + if (!requireTaskPermission(req, res, { + action: finalAction, + target_type: finalTargetType, + target_id: finalTargetId, + node_id: finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null, + selector_type: finalSelectorType, + })) return; + db.updateScheduledTask(id, updates); console.log(`[ScheduledTasks] Updated task id=${id}`); const task = db.getScheduledTask(id); @@ -541,7 +620,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { }); scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -549,6 +627,7 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; db.deleteScheduledTask(id); console.log(`[ScheduledTasks] Deleted task id=${id}`); @@ -561,7 +640,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { }); scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -569,6 +647,7 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const newEnabled = existing.enabled ? 0 : 1; // On enable, a one-shot's persisted run_at restores the exact pinned instant @@ -596,7 +675,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => }); scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -604,6 +682,7 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const scheduler = SchedulerService.getInstance(); if (scheduler.isTaskRunning(id)) { @@ -625,7 +704,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { }); scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -633,6 +711,7 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void const db = DatabaseService.getInstance(); const task = db.getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, task)) return; const runs = db.getAllScheduledTaskRuns(id); @@ -659,7 +738,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void }); scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; @@ -667,6 +745,7 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } + if (!requireTaskExistsPermission(req, res, existing)) return; const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index fb4abfb7..f47f6d1e 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -707,6 +707,8 @@ export interface ScheduledTask { cron_expression: string; enabled: number; created_by: string; + /** The user ID who created this schedule. Null for legacy rows (pre-RBAC) where username resolution failed at migration time. */ + creator_user_id: number | null; created_at: number; updated_at: number; last_run_at: number | null; @@ -1946,6 +1948,18 @@ export class DatabaseService { maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL'); maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0'); maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL'); + maybeAddCol('scheduled_tasks', 'creator_user_id', 'INTEGER DEFAULT NULL'); + + // Backfill creator_user_id from the created_by username column. + // Rows whose username no longer matches a user stay NULL (legacy, + // unrevalidated path — they were created under the old requireAdmin gate). + this.db.exec(` + UPDATE scheduled_tasks + SET creator_user_id = ( + SELECT id FROM users WHERE username = scheduled_tasks.created_by + ) + WHERE creator_user_id IS NULL + `); // Recreate stack_update_status with composite PK (node_id, stack_name). // Original table had stack_name as sole PK which breaks when multiple nodes share stack names. @@ -6185,12 +6199,13 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined; } - public createScheduledTask(task: Omit): number { + public createScheduledTask(task: Omit & { creator_user_id?: number | null }): number { const result = this.db.prepare( - 'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, creator_user_id, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( task.name, task.target_type, task.target_id, task.node_id, task.action, task.cron_expression, task.enabled, task.created_by, + task.creator_user_id ?? null, task.created_at, task.updated_at, task.last_run_at, task.next_run_at, task.last_status, task.last_error, task.prune_targets, task.target_services, task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null, diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 18f1ce49..5b62f417 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser'; import { DatabaseService } from './DatabaseService'; import type { ScheduledTask } from './DatabaseService'; import { LicenseService } from './LicenseService'; +import type { LicenseTier } from './license-types'; import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import DockerController from './DockerController'; import { ComposeService } from './ComposeService'; @@ -35,6 +36,8 @@ import { filterContainersByComposeService } from '../helpers/composeServiceMatch import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { enforcePolicyPreDeploy } from './PolicyEnforcement'; import { summarizeBlockReasons } from '../utils/policy-risk'; +import { resolveTaskPermissionScope, type BackendScheduledAction, type TargetType } from './scheduledActionRegistry'; +import { checkPermissionForSubject } from '../middleware/permissions'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; @@ -42,6 +45,14 @@ const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; const TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000; const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000; +/** Thrown when a scheduled task's creator no longer has permission for the target action. */ +export class TaskAuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TaskAuthorizationError'; + } +} + export class SchedulerService { private static instance: SchedulerService; private intervalId: ReturnType | null = null; @@ -303,6 +314,38 @@ export class SchedulerService { }); try { + // Permission revalidation: for automatic runs, verify the creator still holds + // the required permission. Runs before the node-reachability check so that + // a revoked authorization is surfaced even when the target node is offline + // — a misleading "target node is offline" error must not hide the real + // reason the task cannot execute. Manual runs skip this; the route's + // acting-user check is the gate. Legacy tasks (creator_user_id NULL) + // execute as before. + if (triggeredBy === 'scheduler' && task.creator_user_id != null) { + const creator = db.getUserById(task.creator_user_id); + if (!creator) { + throw new TaskAuthorizationError('Scheduled task no longer authorized: creator account no longer exists.'); + } + const scope = resolveTaskPermissionScope( + task.action as BackendScheduledAction, + task.target_type as TargetType, + task.target_id, + task.node_id, + task.selector_type, + ); + const tier: LicenseTier = LicenseService.getInstance().getTier(); + if (!checkPermissionForSubject( + { username: creator.username, role: creator.role, userId: creator.id }, + tier, + scope.action, + scope.resourceType, + scope.resourceId, + scope.resourceNodeId, + )) { + throw new TaskAuthorizationError('Scheduled task no longer authorized: creator permission was revoked.'); + } + } + // Pre-check: ensure target node exists and is reachable if (task.node_id != null && task.action !== 'snapshot') { const node = db.getNode(task.node_id); @@ -426,6 +469,10 @@ export class SchedulerService { updates.enabled = 0; console.warn(`[SchedulerService] Task "${task.name}" (id=${task.id}) auto-disabled: cron expression invalid`); } + if (error instanceof TaskAuthorizationError) { + updates.enabled = 0; + console.warn(`[SchedulerService] Task "${task.name}" (id=${task.id}) auto-disabled: creator authorization revoked`); + } db.updateScheduledTask(task.id, updates); db.updateScheduledTaskRun(runId, { completed_at: Date.now(), diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts index 295c94a2..b71ada6f 100644 --- a/backend/src/services/scheduledActionRegistry.ts +++ b/backend/src/services/scheduledActionRegistry.ts @@ -1,8 +1,9 @@ /** * Single source of truth for scheduled-operation action metadata that the - * backend needs for validation. The route layer derives its allow-list and - * action/target compatibility checks from this table, so adding a new action - * means adding one entry here (plus its execution logic in SchedulerService). + * backend needs for validation and authorization. The route layer derives its + * allow-list, action/target compatibility checks, and permission enforcement + * from this table, so adding a new action means adding one entry here + * (plus its execution logic in SchedulerService). * * The frontend keeps its own richer registry (labels, categories, tones) in * `frontend/src/lib/scheduledActions.ts`; the two cannot share a module because @@ -10,6 +11,9 @@ * each side. */ +import type { PermissionAction } from '../middleware/permissions'; +import type { ResourceType } from './DatabaseService'; + export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const; export type TargetType = typeof VALID_TARGET_TYPES[number]; @@ -19,6 +23,19 @@ export interface BackendScheduledActionDefinition { readonly targetTypes: readonly TargetType[]; readonly requiresNode: boolean; readonly nodeScope?: 'local'; + /** Permission required to create, edit, enable, run, or delete a schedule for this action. */ + readonly permission: PermissionAction; +} + +/** + * Permission scope resolved from a task's action, target, and node identity. + * When `resourceType` is omitted the check is unscoped (global role matrix only). + */ +export interface ScheduledActionPermissionScope { + readonly action: PermissionAction; + readonly resourceType?: ResourceType; + readonly resourceId?: string; + readonly resourceNodeId?: number | null; } /** @@ -26,15 +43,15 @@ export interface BackendScheduledActionDefinition { * in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ..."). */ export const BACKEND_SCHEDULED_ACTIONS = [ - { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true }, - { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false }, - { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, - { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true }, - { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, - { id: 'auto_backup', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true }, - { id: 'auto_down', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true }, + { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false, permission: 'node:manage' as const }, + { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' as const, permission: 'system:settings' as const }, + { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' as const, permission: 'node:manage' as const }, + { id: 'auto_backup',targetTypes: ['stack'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_down', targetTypes: ['stack'], requiresNode: true, permission: 'stack:deploy' as const }, + { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true, permission: 'stack:deploy' as const }, ] as const satisfies readonly BackendScheduledActionDefinition[]; export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id']; @@ -83,3 +100,58 @@ export function validateActionTarget(action: BackendScheduledAction, targetType: export function getScheduledActionDefinition(action: BackendScheduledAction): BackendScheduledActionDefinition | undefined { return ACTION_BY_ID.get(action); } + +/** + * Resolve the permission scope for a scheduled action + target combination. + * This is the single source of truth consumed by the route layer and the + * scheduler revalidation path. Scope resolution is per-action, not per + * target-type bucket. + */ +export function resolveTaskPermissionScope( + action: BackendScheduledAction, + targetType: TargetType, + targetId: string | null, + nodeId: number | null, + _selectorType?: string | null, +): ScheduledActionPermissionScope { + const def = ACTION_BY_ID.get(action); + const basePermission = def?.permission ?? 'stack:deploy'; + + switch (action) { + case 'restart': + case 'auto_stop': + case 'auto_start': { + if (targetType === 'container') { + return { action: 'node:manage', resourceType: 'node', resourceId: nodeId != null ? String(nodeId) : undefined, resourceNodeId: nodeId }; + } + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + } + case 'auto_down': + case 'auto_backup': + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + + case 'update': { + if (targetType === 'stack') { + return { action: basePermission, resourceType: 'stack', resourceId: targetId ?? undefined, resourceNodeId: nodeId }; + } + if (nodeId != null) { + return { action: 'node:manage', resourceType: 'node', resourceId: String(nodeId), resourceNodeId: nodeId }; + } + return { action: 'node:manage' }; + } + + case 'scan': + return { action: basePermission, resourceType: 'node', resourceId: nodeId != null ? String(nodeId) : undefined, resourceNodeId: nodeId }; + + case 'prune': + return { action: basePermission }; + + case 'snapshot': + return { action: basePermission }; + + default: { + const exhaustive: never = action; + return { action: exhaustive as never }; + } + } +} diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index 29221c29..c437817e 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -55,7 +55,7 @@ function mockDeployer() { function mockPaidAdmin() { mockAuth( true, - (p) => p === 'system:audit' || p === 'system:console' || p === 'node:read', + (p) => p === 'system:audit' || p === 'system:console' || p === 'node:read' || p === 'stack:deploy' || p === 'node:manage', ); mockLicense(true); } @@ -63,14 +63,14 @@ function mockPaidAdmin() { // Synthetic gate-isolation helper: omits system:audit so tests can assert the // Audit hide path. Real Admin always includes system:audit in the permission matrix. function mockCommunityAdmin() { - mockAuth(true, (p) => p === 'system:console' || p === 'node:read'); + mockAuth(true, (p) => p === 'system:console' || p === 'node:read' || p === 'stack:deploy'); mockLicense(false); } function mockCommunityAdminWithAudit() { mockAuth( true, - (p) => p === 'system:audit' || p === 'system:console' || p === 'node:read', + (p) => p === 'system:audit' || p === 'system:console' || p === 'node:read' || p === 'stack:deploy', ); mockLicense(false); } diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index 70432483..e5a656f8 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -28,6 +28,7 @@ function makeReachCtx(over: Partial = {}): ReachabilityCont licenseStatus: 'ready', experimental: true, experimentalReady: true, + scheduledOpsAccessible: false, ...over, }; } diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 811a7f10..75b1975e 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -17,6 +17,7 @@ import { type ReachabilityContext, } from '@/lib/routing/reachability'; import { useExperimental } from '@/hooks/useExperimental'; +import { canScheduleAny } from '@/lib/scheduledActions'; import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; @@ -34,12 +35,18 @@ interface UseViewNavigationStateOptions { export function useViewNavigationState(options?: UseViewNavigationStateOptions) { const { onNavigateToDashboard, hasFleetCapability = false, containerLabelsEnabled = false } = options ?? {}; - const { isAdmin, can, permissionsStatus } = useAuth(); + const { isAdmin, can, permissionsStatus, permissions } = useAuth(); const { isPaid, licenseStatus } = useLicense(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { experimental, experimentalReady } = useExperimental(); + const scheduledOpsAccessible = useMemo(() => canScheduleAny( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + (action, resourceType, resourceId, nodeId) => can(action as Parameters[0], resourceType, resourceId, nodeId), + permissions, + ), [can, permissions]); + const initialRoute = readUrlRouteState(); const [activeView, setActiveView] = useState(initialRoute.activeView); @@ -62,7 +69,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) licenseStatus, experimental, experimentalReady, - }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady]); + scheduledOpsAccessible, + }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady, scheduledOpsAccessible]); const handleOpenSettings = useCallback((section?: SectionId) => { if (section) setSettingsSection(section); diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index d7bd8bea..9a968c3c 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -40,7 +40,10 @@ import { RISK_BADGE_CLASSES, RISK_DOT_CLASSES, RISK_LABEL, + canScheduleAction, + canScheduleActionAnywhere, } from '@/lib/scheduledActions'; +import { useAuth } from '@/context/AuthContext'; import { LabelNameAutocomplete, type LabelNameSuggestion } from '@/components/labels/LabelNameAutocomplete'; const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes']; @@ -127,6 +130,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [simpleSchedule, setSimpleSchedule] = useState(DEFAULT_SIMPLE_SCHEDULE); const [simpleReplacedCron, setSimpleReplacedCron] = useState(false); const [formEnabled, setFormEnabled] = useState(true); + const { can, permissions } = useAuth(); const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(DEFAULT_PRUNE_TARGETS); const [formTargetServices, setFormTargetServices] = useState([]); @@ -574,12 +578,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]); const actionOptions = useMemo( () => - SCHEDULED_ACTIONS.map(o => ({ - value: o.id, - label: o.label, - group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label, - })), - [], + SCHEDULED_ACTIONS + .filter(o => canScheduleActionAnywhere(can, o, permissions)) + .map(o => ({ + value: o.id, + label: o.label, + group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label, + })), + [can, permissions], ); // Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers. const localNodeOptions = useMemo( @@ -607,6 +613,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const scheduleInvalid = scheduleMode === 'simple' ? !!simpleCronError : (!formCron || !!cronFieldError); + const canSaveWithCurrentTarget = useMemo(() => { + if (!currentAction) return false; + return canScheduleAction(can, currentAction, { + nodeId: formNodeId ? Number(formNodeId) : null, + stackName: formTargetId || null, + labelScope: formLabelScope === 'node' ? 'node' : 'fleet', + }); + }, [can, currentAction, formNodeId, formTargetId, formLabelScope]); + const isSaveDisabled = saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) @@ -616,7 +631,16 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p || (formAction === 'update-by-label' && ( !formSelectorValue.trim() || (formLabelScope === 'node' && !formNodeId) - )); + )) + || !canSaveWithCurrentTarget; + + const saveDisabledReason = useMemo((): string | null => { + if (saving || !currentAction || !formName || scheduleInvalid) return null; + if (!canSaveWithCurrentTarget) { + return 'You do not have permission to schedule this action on the selected target.'; + } + return null; + }, [saving, currentAction, formName, scheduleInvalid, canSaveWithCurrentTarget]); const windowEnd = now + TIMELINE_WINDOW_MS; const timelinePills = filteredTasks @@ -1282,6 +1306,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p } /> + {saveDisabledReason && ( +

{saveDisabledReason}

+ )} {/* Delete Confirmation */} diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx index 0b213182..a210701c 100644 --- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx +++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx @@ -14,6 +14,19 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, })); +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ + can: () => true, + permissions: { + globalRole: 'admin' as const, + globalPermissions: ['stack:deploy', 'node:manage', 'system:settings'] as string[], + scopedPermissions: {}, + }, + isAdmin: true, + permissionsStatus: 'ready' as const, + permissionsReady: true, + }), +})); import { apiFetch, fetchForNode } from '@/lib/api'; import { SCHEDULED_ACTIONS } from '@/lib/scheduledActions'; diff --git a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts index 2b885a2e..a577be52 100644 --- a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts +++ b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts @@ -25,8 +25,10 @@ export function useNextAutoUpdateRun(): number | null { const abortRef = useRef(null); useEffect(() => { - // The list endpoint is admin-only; non-admins would 403 on every poll. - // Skip all fetching/polling/listeners for them and report no scheduled run. + // This indicator is shown fleet-wide in the sidebar regardless of the active + // view; gating to admin avoids showing a partial "next auto-update run" to a + // role with only scoped permissions. Revisit when scoped roles are extended + // to this indicator. if (!isAdmin) { setNextRunAt(null); // eslint-disable-line react-hooks/set-state-in-effect return; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 55b8316b..d6f6ca7e 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -138,12 +138,18 @@ describe('useStackMenuItems', () => { expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); - it('hides Schedule task when not admin', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false }))); + it('hides Schedule task when canDeploy is false', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canDeploy: false }))); const lifecycle = result.current.find(g => g.id === 'lifecycle'); expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); }); + it('shows Schedule task when canDeploy is true even when not admin', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false, canDeploy: true }))); + const lifecycle = result.current.find(g => g.id === 'lifecycle'); + expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeTruthy(); + }); + it('includes Mute submenu in Inspect when canMuteNotifications', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true }))); const inspect = result.current.find(g => g.id === 'inspect')!; @@ -211,11 +217,8 @@ describe('useStackMenuItems', () => { canDeploy: false, menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true }, }))); - const lifecycle = result.current.find(g => g.id === 'lifecycle')!; - const ids = lifecycle.items.map(i => i.id); - expect(ids).not.toContain('deploy'); - expect(ids).not.toContain('take-down'); - expect(ids).toEqual(['schedule']); + // With canDeploy false, the entire lifecycle group is empty and omitted. + expect(result.current.find(g => g.id === 'lifecycle')).toBeUndefined(); }); it('disables take down for the self stack', () => { diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index f1ff6953..07bf312b 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -20,7 +20,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, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, @@ -89,7 +89,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack }); } - if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); + if (canDeploy) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -109,7 +109,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return groups; }, [ - stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, showTakeDown, openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask, diff --git a/frontend/src/lib/navigation/buildNavigationModel.test.ts b/frontend/src/lib/navigation/buildNavigationModel.test.ts index cf4a1e37..3bc76af7 100644 --- a/frontend/src/lib/navigation/buildNavigationModel.test.ts +++ b/frontend/src/lib/navigation/buildNavigationModel.test.ts @@ -14,6 +14,7 @@ function makeCtx(overrides: Partial = {}): ReachabilityCont licenseStatus: 'ready', experimental: true, experimentalReady: true, + scheduledOpsAccessible: true, ...overrides, }; } diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts index 328f85c8..20f1ed5e 100644 --- a/frontend/src/lib/routing/reachability.test.ts +++ b/frontend/src/lib/routing/reachability.test.ts @@ -20,6 +20,7 @@ function ctx(over: Partial = {}): ReachabilityContext { licenseStatus: 'ready', experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, ...over, }; } @@ -71,6 +72,7 @@ describe('reachability', () => { isPaid: false, experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, can: (a) => a === 'system:console', }); expect(isViewHidden('host-console', community)).toBe(false); @@ -102,7 +104,8 @@ describe('reachability', () => { }); it('does not hide fleet-mesh settings for experimental off', () => { - const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true }); + const off = ctx({ experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, isAdmin: true }); expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false); }); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts index 02637dfa..adbce787 100644 --- a/frontend/src/lib/routing/reachability.ts +++ b/frontend/src/lib/routing/reachability.ts @@ -19,6 +19,8 @@ export interface ReachabilityContext { experimental: boolean; /** True once /meta experimental has settled (success or fail-closed). */ experimentalReady: boolean; + /** Whether the user can reach the Scheduled Operations view (global or scoped grants). */ + scheduledOpsAccessible: boolean; } /** RBAC/tier gates apply only when permission and license metadata are ready. */ @@ -41,10 +43,11 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea if (ctx.isRemote && HUB_ONLY_VIEWS.has(view)) return true; if ( !ctx.isAdmin && - (view === 'global-observability' || view === 'auto-updates' || view === 'scheduled-ops') + (view === 'global-observability' || view === 'auto-updates') ) { return true; } + if (view === 'scheduled-ops' && !ctx.scheduledOpsAccessible) return true; if (!ctx.can('node:read') && (view === 'fleet' || view === 'networking')) return true; if (view === 'host-console') return !ctx.can('system:console'); // Permission-driven on Community and Admiral (14-day window vs paid depth is in-view). diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts index 1252800d..2ef26328 100644 --- a/frontend/src/lib/scheduledActions.ts +++ b/frontend/src/lib/scheduledActions.ts @@ -1,4 +1,5 @@ import type { ScheduledTask } from '@/types/scheduling'; +import type { PermissionAction } from '@/context/AuthContext'; /** * Single source of truth for scheduled-operation action metadata on the @@ -86,6 +87,8 @@ export interface ScheduledActionDefinition { helperText: string; /** Risk level shown as a coloured chip next to the helper text. */ riskLevel: ScheduledActionRiskLevel; + /** Permission required to schedule this action (mirrors backend registry). */ + permission: PermissionAction; } /** Action pre-selected when the create modal opens. Decoupled from picker order. */ @@ -94,24 +97,24 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart'; /** Ordered for the create-flow action picker, grouped by category. */ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [ // Lifecycle - { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' }, - { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' }, - { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' }, - { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' }, - { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' }, - { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' }, - { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' }, - { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' }, + { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe', permission: 'stack:deploy' }, + { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers', permission: 'stack:deploy' }, + { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Updates - { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' }, - { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' }, - { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change' }, + { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, + { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Security - { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' }, + { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only', permission: 'node:manage' }, // Maintenance - { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' }, + { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive', permission: 'system:settings' }, // Backups - { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' }, + { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe', permission: 'node:manage' }, ]; const ACTION_BY_ID = new Map(SCHEDULED_ACTIONS.map(a => [a.id, a])); @@ -201,3 +204,93 @@ export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [ { key: 'maintenance', label: 'Upkeep', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' }, { key: 'backups', label: 'Backups', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)' }, ]; + +// ── Permission helpers ────────────────────────────────────────────────────── + +/** Permission actions that authorize any scheduleable action. */ +const SCHEDULABLE_ACTIONS: readonly PermissionAction[] = ['stack:deploy', 'node:manage', 'system:settings']; + +export interface ScheduleActionTarget { + nodeId?: number | null; + stackName?: string | null; + labelScope?: 'fleet' | 'node'; +} + +/** + * Check whether the user can schedule the given action on the given target. + * Scope resolution mirrors the backend `resolveTaskPermissionScope`: per-action, + * not per target-type bucket. + */ +export function canScheduleAction( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + target: ScheduleActionTarget, +): boolean { + // Stack lifecycle: scoped to (nodeId, stackName) + if (def.targetType === 'stack') { + return can(def.permission, 'stack', target.stackName ?? undefined, target.nodeId); + } + // Prune: always unscoped (admin-only via system:settings in the role matrix) + if (def.id === 'prune') { + return can(def.permission); + } + // Snapshot: unscoped (spans all nodes) + if (def.id === 'snapshot') { + return can(def.permission); + } + // Container targets + scan: node-scoped + if (def.targetType === 'container' || def.id === 'scan') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet update with specific node (non-label): node-scoped + if (def.id === 'update-fleet') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet-wide label update: unscoped when no node; node-scoped when node + if (def.id === 'update-by-label') { + if (target.labelScope === 'node' && target.nodeId != null) { + return can(def.permission, 'node', String(target.nodeId), target.nodeId); + } + return can(def.permission); + } + return can(def.permission); +} + +/** + * True when the user can schedule at least one action. Used to determine whether + * the Scheduled Operations view and its "New scheduled task" button should be + * reachable. + */ +export function canScheduleAny( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + // Global role check + if (can('stack:deploy') || can('node:manage') || can('system:settings')) return true; + // Scoped permissions check: any scoped grant covering a scheduleable action + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if (actions.some(a => (SCHEDULABLE_ACTIONS as readonly string[]).includes(a))) return true; + } + } + return false; +} + +/** + * True when the user can schedule this action on at least one possible target + * (global role or any scoped grant). Used to filter the action picker so + * actions the user can NEVER schedule are not shown. + */ +export function canScheduleActionAnywhere( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + if (can(def.permission)) return true; + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if ((actions as readonly string[]).includes(def.permission)) return true; + } + } + return false; +} diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index 3c81da69..244b7459 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -8,6 +8,8 @@ export interface ScheduledTask { cron_expression: string; enabled: number; created_by: string; + /** The user ID who created this schedule. Null for legacy rows (pre-RBAC). */ + creator_user_id?: number | null; created_at: number; updated_at: number; last_run_at: number | null;