feat: add target-aware RBAC authorization to Scheduled Operations (#1745)

* 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.'
This commit is contained in:
Anso
2026-08-02 01:15:37 -04:00
committed by GitHub
parent 2e2b095b00
commit 209c9c5d53
21 changed files with 966 additions and 81 deletions
@@ -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);
}
@@ -28,6 +28,7 @@ function makeReachCtx(over: Partial<ReachabilityContext> = {}): ReachabilityCont
licenseStatus: 'ready',
experimental: true,
experimentalReady: true,
scheduledOpsAccessible: false,
...over,
};
}
@@ -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<typeof can>[0], resourceType, resourceId, nodeId),
permissions,
), [can, permissions]);
const initialRoute = readUrlRouteState();
const [activeView, setActiveView] = useState<ActiveView>(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);
@@ -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<SimpleSchedule>(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<string[]>(DEFAULT_PRUNE_TARGETS);
const [formTargetServices, setFormTargetServices] = useState<string[]>([]);
@@ -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
</Button>
}
/>
{saveDisabledReason && (
<p className="px-6 pb-4 text-xs text-muted-foreground">{saveDisabledReason}</p>
)}
</Modal>
{/* Delete Confirmation */}
@@ -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';
@@ -25,8 +25,10 @@ export function useNextAutoUpdateRun(): number | null {
const abortRef = useRef<AbortController | null>(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;
@@ -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', () => {
+3 -3
View File
@@ -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,
@@ -14,6 +14,7 @@ function makeCtx(overrides: Partial<ReachabilityContext> = {}): ReachabilityCont
licenseStatus: 'ready',
experimental: true,
experimentalReady: true,
scheduledOpsAccessible: true,
...overrides,
};
}
@@ -20,6 +20,7 @@ function ctx(over: Partial<ReachabilityContext> = {}): 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);
});
+4 -1
View File
@@ -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).
+107 -14
View File
@@ -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<string, ScheduledActionDefinition>(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<string, PermissionAction[]> } | 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<string, PermissionAction[]> } | 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;
}
+2
View File
@@ -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;