fix(schedules): align Schedules surface with backend tier gate for Skipper admins (#1047)

The Schedules sidebar entry was hidden from Skipper admins even though the
backend permits them to create and run update, scan, and snapshot schedules.
The action picker also showed all 10 actions to every paid admin, so a Skipper
selecting Restart, Prune, or any auto_* lifecycle action would 403 on submit.

Changes:
- Extract SKIPPER_SCHEDULED_ACTIONS as the single source of truth in
  tierGates.ts; both requireScheduledTaskTier and the GET /scheduled-tasks
  list filter now reference it (replaces a duplicate local constant in
  scheduledTasks.ts).
- Move the Schedules nav entry from the Admiral block into the
  isPaid && isAdmin block in useViewNavigationState.ts, mirroring the
  existing Auto-Update pattern. Console and Audit stay Admiral-only.
- Filter the create-form action picker in ScheduledOperationsView.tsx by
  license variant. Skipper sees Auto-update Stack, Auto-update All Stacks,
  Fleet Snapshot, and Vulnerability Scan; Admiral sees the full set.
- openCreate now defaults formAction to the first visible option so Skipper
  starts with a valid choice instead of the Admiral-only Restart.

Tests:
- Add Skipper-variant POST coverage in scheduled-tasks-routes.test.ts:
  three allow cases (update / scan / snapshot) and a six-action rejection
  loop covering restart / prune / auto_backup / auto_stop / auto_down /
  auto_start.
- Flip the Skipper assertion in useViewNavigationState.test.tsx to expect
  scheduled-ops alongside auto-updates.
This commit is contained in:
Anso
2026-05-14 10:31:20 -04:00
committed by GitHub
parent 5461bc316b
commit 44e40afb62
6 changed files with 72 additions and 10 deletions
@@ -409,6 +409,50 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
});
});
describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
beforeEach(() => {
variantSpy.mockReturnValue('skipper');
});
it('allows Skipper admins to create update tasks', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'skipper-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
action: 'update', cron_expression: '0 3 * * *', enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.action).toBe('update');
});
it('allows Skipper admins to create scan tasks', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'skipper-scan', target_type: 'system', node_id: 1,
action: 'scan', cron_expression: '0 0 * * *', enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.action).toBe('scan');
});
it('allows Skipper admins to create snapshot tasks', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'skipper-snapshot', target_type: 'fleet', node_id: 1,
action: 'snapshot', cron_expression: '0 1 * * *', enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.action).toBe('snapshot');
});
for (const action of ['restart', 'prune', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
it(`rejects Skipper admins from creating ${action} tasks with 403`, async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1,
action, cron_expression: '0 3 * * *', enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
}
});
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
it('can toggle delete_after_run via update', async () => {
const now = Date.now();
+5 -2
View File
@@ -59,9 +59,12 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
return true;
};
/** Tier gate for scheduled tasks: `update`, `scan`, and `snapshot` require Skipper+, everything else requires Admiral. */
/** Scheduled task actions a Skipper-tier license may create and view. All other actions are Admiral-only. */
export const SKIPPER_SCHEDULED_ACTIONS: ReadonlySet<string> = new Set(['update', 'scan', 'snapshot']);
/** Tier gate for scheduled tasks: SKIPPER_SCHEDULED_ACTIONS require Skipper+, everything else requires Admiral. */
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (action === 'update' || action === 'scan' || action === 'snapshot') return requirePaid(req, res);
if (SKIPPER_SCHEDULED_ACTIONS.has(action)) return requirePaid(req, res);
return requireAdmiral(req, res);
};
+2 -3
View File
@@ -3,7 +3,7 @@ import { CronExpressionParser } from 'cron-parser';
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
import { LicenseService } from '../services/LicenseService';
import { SchedulerService } from '../services/SchedulerService';
import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middleware/tierGates';
import { requirePaid, requireAdmin, requireScheduledTaskTier, SKIPPER_SCHEDULED_ACTIONS } from '../middleware/tierGates';
import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
@@ -19,7 +19,6 @@ type TargetType = typeof VALID_TARGET_TYPES[number];
type ScheduledAction = typeof VALID_ACTIONS[number];
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
const SKIPPER_VISIBLE_ACTIONS = new Set<ScheduledAction>(['update', 'scan', 'snapshot']);
/**
* Validate that the target_type is compatible with the action. Each action
@@ -113,7 +112,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
// Skipper users see v1 fleet-maintenance tasks; Admiral sees all.
const ls = LicenseService.getInstance();
if (ls.getVariant() !== 'admiral') {
tasks = tasks.filter(t => SKIPPER_VISIBLE_ACTIONS.has(t.action as ScheduledAction));
tasks = tasks.filter(t => SKIPPER_SCHEDULED_ACTIONS.has(t.action));
}
// Split Auto-Update and Scheduled Operations into distinct views.
const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined;
@@ -227,14 +227,14 @@ describe('useViewNavigationState', () => {
// ── navItems: skipper admin ────────────────────────────────────────────────
it('navItems for skipper paid admin contains auto-updates but not admiral items', () => {
it('navItems for skipper paid admin contains schedules and auto-updates but not admiral items', () => {
mockSkipperAdmin();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('auto-updates');
expect(values).toContain('scheduled-ops');
expect(values).not.toContain('host-console');
expect(values).not.toContain('audit-log');
expect(values).not.toContain('scheduled-ops');
});
// ── navItems: hub-only gating on remote node ───────────────────────────────
@@ -108,11 +108,11 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
];
if (isPaid && isAdmin) {
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
if (isPaid && license?.variant === 'admiral') {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
return isRemote
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))
@@ -13,11 +13,22 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che
import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { Combobox } from '@/components/ui/combobox';
import { useLicense } from '@/context/LicenseContext';
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
const UPDATE_FLEET_ACTION = 'update-fleet' as const;
// Mirrors backend `SKIPPER_SCHEDULED_ACTIONS` in tierGates.ts. Picker options
// whose backend action falls outside this set are Admiral-only and hidden from
// Skipper users so the Combobox never offers a choice the API will reject.
const SKIPPER_BACKEND_ACTIONS: ReadonlySet<string> = new Set(['update', 'scan', 'snapshot']);
function isActionAllowedForVariant(option: { value: string; backendAction?: string }, variant: string | null | undefined): boolean {
if (variant === 'admiral') return true;
return SKIPPER_BACKEND_ACTIONS.has(option.backendAction ?? option.value);
}
const ACTION_OPTIONS: Array<{
value: string;
label: string;
@@ -75,6 +86,11 @@ interface ScheduledOperationsViewProps {
}
export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) {
const { license } = useLicense();
const visibleActionOptions = useMemo(
() => ACTION_OPTIONS.filter(o => isActionAllowedForVariant(o, license?.variant)),
[license?.variant]
);
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<'timeline' | 'table'>('timeline');
@@ -209,7 +225,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
setEditingTask(null);
setFormName('');
setFormAction('restart');
setFormAction(visibleActionOptions[0]?.value ?? 'restart');
setFormTargetId(prefillData?.stackName ?? '');
setFormNodeId(nodeId);
setFormCron('0 3 * * *');
@@ -676,7 +692,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<div className="space-y-2">
<Label>Action</Label>
<Combobox
options={ACTION_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
options={visibleActionOptions.map(o => ({ value: o.value, label: o.label }))}
value={formAction}
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
placeholder="Select action..."