mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
209c9c5d53
* 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.'
158 lines
6.9 KiB
TypeScript
158 lines
6.9 KiB
TypeScript
/**
|
|
* Single source of truth for scheduled-operation action metadata that the
|
|
* 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
|
|
* the packages build in isolation. The shapes are kept in lockstep by tests on
|
|
* 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];
|
|
|
|
export interface BackendScheduledActionDefinition {
|
|
readonly id: string;
|
|
/** Target types this action accepts. `update` is the only multi-target action. */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Ordered so the `VALID_ACTIONS` list matches the human-readable error message
|
|
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
|
|
*/
|
|
export const BACKEND_SCHEDULED_ACTIONS = [
|
|
{ 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'];
|
|
|
|
export const VALID_ACTIONS: readonly BackendScheduledAction[] =
|
|
BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
|
|
|
|
/**
|
|
* Human-readable allow-list for the route's 400 response. Built from
|
|
* VALID_ACTIONS so a new action cannot leave this enumeration stale.
|
|
*/
|
|
export const INVALID_ACTION_MESSAGE =
|
|
`Invalid action. Must be ${VALID_ACTIONS.join(', ').replace(/, ([^,]+)$/, ', or $1')}.`;
|
|
|
|
const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefinition>(
|
|
BACKEND_SCHEDULED_ACTIONS.map(a => [a.id, a]),
|
|
);
|
|
|
|
/**
|
|
* Per-action mismatch message. The wording differs per action and is part of
|
|
* the API contract, so it is kept explicit rather than templated.
|
|
*/
|
|
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
|
|
restart: 'Restart action requires target_type "stack" or "container".',
|
|
snapshot: 'Snapshot action requires target_type "fleet".',
|
|
prune: 'Prune action requires target_type "system".',
|
|
update: 'Update action requires target_type "stack" or "fleet".',
|
|
scan: 'Scan action requires target_type "system".',
|
|
auto_backup: 'auto_backup action requires target_type "stack".',
|
|
auto_stop: 'auto_stop action requires target_type "stack" or "container".',
|
|
auto_down: 'auto_down action requires target_type "stack".',
|
|
auto_start: 'auto_start action requires target_type "stack" or "container".',
|
|
};
|
|
|
|
/**
|
|
* Validate that the target_type is compatible with the action. Returns an error
|
|
* message on mismatch and null otherwise. Callers must already have confirmed
|
|
* the action is in `VALID_ACTIONS`.
|
|
*/
|
|
export function validateActionTarget(action: BackendScheduledAction, targetType: TargetType): string | null {
|
|
const def = ACTION_BY_ID.get(action);
|
|
if (!def) return null;
|
|
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
}
|