mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user