From 209c9c5d532df0de77307ff21f77037d9a09c7d1 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 01:15:37 -0400 Subject: [PATCH] feat: add target-aware RBAC authorization to Scheduled Operations (#1745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add target-aware RBAC authorization to Scheduled Operations Replace the blanket requireAdmin gate on all 9 scheduled-tasks endpoints with per-action permission checks derived from the centralized action registry. Each scheduled action now declares the existing permission it requires: stack lifecycle actions need stack:deploy on the target stack, node-wide operations need node:manage on the target node, prune stays admin-only via system:settings, and snapshot requires unscoped node:manage. Key changes: - Backend registry: add permission field and resolveTaskPermissionScope - Routes: replace requireAdmin with requireTaskPermission, filter GET listing by permission, add two-phase PUT check - Scheduler: revalidate creator permission at execution time, auto-disable on revocation (TaskAuthorizationError) - Database: new creator_user_id column with migration and backfill - Frontend: canScheduleAction/canScheduleAny helpers, reachability gate via scheduledOpsAccessible, stack context menu uses canDeploy not isAdmin, permissions field on all ScheduledActionDefinitions - Expose checkPermissionForSubject for in-process callers (scheduler) No new PermissionAction values are added. Uses the existing stack:deploy, node:manage, and system:settings matrix. Scoped Admiral grants work on the exact (nodeId, stackName) target per SEN-438. * test: add RBAC coverage for scheduled operations authorization * test: update frontend tests for scheduled-ops RBAC gate changes - useStackMenuItems: gate Schedule task on canDeploy not isAdmin; add test for canDeploy=true, non-admin case - buildNavigationModel: default scheduledOpsAccessible to true (default ctx is admin, who can always schedule) - useViewNavigationState: add stack:deploy to admin can() mocks so canScheduleAny resolves correctly * fix: keep checkPermission unchanged, add checkPermissionForSubject standalone The earlier refactoring that made checkPermission delegate to checkPermissionForSubject changed the call order of effectiveTier(req) relative to the admin bypass, which subtly broke the Community tier clamping in the audit-log route. Keep checkPermission byte-identical to the original and expose checkPermissionForSubject as a standalone function used only by the scheduler revalidation path. * fix: prefix unused selectorType parameter in resolveTaskPermissionScope * fix: address audit findings — existence oracle, revalidation test, action filtering Three corrections from the independent PR audit: RC-1 (action filtering): Wire canScheduleActionAnywhere into the action picker in ScheduledOperationsView so actions the user can never schedule (prune without system:settings, node:manage actions without a scoped grant) are filtered from the picker entirely. Add canScheduleAction check on the Create button against the currently selected target, so the submit button is disabled when the caller cannot schedule the chosen action on the chosen target. RC-2 (existence oracle): Six by-ID endpoints (GET /:id, DELETE /:id, PATCH /:id/toggle, POST /:id/run, GET /:id/runs/export, GET /:id/runs) now return a uniform 404 when permission is denied on an existing task, so an unauthorized caller cannot distinguish "task does not exist" from "task exists but you are not authorized." Added requireTaskExistsPermission helper for the 404 variant; POST create and PUT merged-scope checks keep requireTaskPermission (403). RC-3 (revalidation test): Fixed the orphan-creator test to assert the post-execution task state (auto-disabled, explicit error message) rather than wrapping executeTask in a try/catch with a no-op else branch, since executeTask catches TaskAuthorizationError internally and returns normally. Added canScheduleActionAnywhere helper and AuthContext mock to ScheduledOperationsView tests (38/38 pass). * fix: remove unused TaskAuthorizationError import from test * fix: use 404 on PUT phase-1 unauthorized-access check The PUT two-phase check's first phase (ownership verification) now uses requireTaskExistsPermission (404) instead of a manual 403, consistent with the six other by-ID endpoints. This prevents a caller from probing task-ID existence via the PUT route. * fix: address QA findings — reorder prechecks, surface permission reason Two live-confirmed fixes from the 3-node fleet QA pass: Finding #4 (offline-node error ordering): Swap the order of the node- reachability precheck and the creator-permission revalidation in SchedulerService.executeTask. Authorization now runs first, so a revoked grant is always surfaced as an explicit auto-disable with a clear error message, even when the target node is offline. Previously the reachability check ran first, hiding the revocation behind a misleading "target node is offline" error and leaving the task enabled indefinitely while the node was down. Finding #7 (unexplained Save dead-end): When the Create/Update button is disabled because canScheduleAction denies the selected action-target combination, a muted text line now appears below the button: "You do not have permission to schedule this action on the selected target." This gives scoped users meaningful feedback instead of a silently disabled button with no explanation. * chore: remove redundant !!formName guards The earlier short-circuit conditions in isSaveDisabled and saveDisabledReason already guarantee formName is truthy by the time the canSaveWithCurrentTarget check runs. The !!formName guard is a no-op — flagged by GitHub code-quality as 'Useless conditional: This negation always evaluates to true.' --- .../__tests__/scheduled-tasks-rbac.test.ts | 456 ++++++++++++++++++ .../__tests__/scheduled-tasks-routes.test.ts | 6 +- .../src/__tests__/scheduler-service.test.ts | 1 + backend/src/middleware/permissions.ts | 57 +++ backend/src/routes/scheduledTasks.ts | 129 ++++- backend/src/services/DatabaseService.ts | 19 +- backend/src/services/SchedulerService.ts | 47 ++ .../src/services/scheduledActionRegistry.ts | 96 +++- .../__tests__/useViewNavigationState.test.tsx | 6 +- .../EditorLayout/hooks/useUrlSync.test.ts | 1 + .../hooks/useViewNavigationState.ts | 12 +- .../components/ScheduledOperationsView.tsx | 41 +- .../ScheduledOperationsView.test.tsx | 13 + .../sidebar/useNextAutoUpdateRun.ts | 6 +- .../__tests__/useStackMenuItems.test.tsx | 17 +- frontend/src/hooks/useStackMenuItems.tsx | 6 +- .../navigation/buildNavigationModel.test.ts | 1 + frontend/src/lib/routing/reachability.test.ts | 5 +- frontend/src/lib/routing/reachability.ts | 5 +- frontend/src/lib/scheduledActions.ts | 121 ++++- frontend/src/types/scheduling.ts | 2 + 21 files changed, 966 insertions(+), 81 deletions(-) create mode 100644 backend/src/__tests__/scheduled-tasks-rbac.test.ts 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;