mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 06:07:58 +00:00
41bf075eb0
* feat(recovery): make rollback-recovery image lifecycle visible and controllable GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold images during automatic updates and how to clean them up. That surfaced a real safety bug alongside the missing visibility: the manual single-image delete route did not consult the held-image predicate every other deletion path already honors, so a user could delete a rollback-protected image straight through the Images tab and silently break automatic recovery for that update. A short/truncated id also bypassed the predicate's full-id lookup. Fixes: - POST /images/delete now resolves the submitted id to its canonical form and checks the unified held-image predicate before deleting, returning 409 IMAGE_HELD_FOR_ROLLBACK for a protected image. - The Images tab no longer mislabels a protected image as plain "Unused"; a fully-synthetic hold image is kept out of the generic inventory entirely and surfaced instead in a new Resources -> Rollback tab, with an additive "Rollback protected" badge for images that still carry a normal tag too. New capability: - Two settings (Deploy Guardrails): superseded-generation retention (days, replaces a hardcoded 7) and a cap on retained generations per stack. - A new Resources -> Rollback tab lists every generation (stack, short id, state, retention) with an admin-gated manual release action, including releasing the current generation with an explicit warning that automatic rollback becomes unavailable until the next successful update. Release is a single atomic, server-revalidated transition so a stale UI read can never release a row that has since become ineligible. Also consolidated three near-duplicate implementations of the held-image predicate (two of which relied on a require() of a sibling .ts file that silently failed to resolve under the test runner and was never actually exercised by a real test before this change) into one shared module. Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row no longer exists (DB restore, node re-add) is invisible in both the Images and Rollback tabs with no UI path to reclaim it. * fix(audit): add summary mapping for rollback generation release * fix(security): sanitize prune target in log sinks and cover release RBAC Closes two open js/log-injection findings on the system prune route by applying the same inline sanitizeForLog barrier the rest of the file already uses. The prune target is validated against an enum by parsePruneTargets before reaching these sinks, so the findings were false positives, but the barrier is cheap and removes the standing alerts on a file this change already touches. Also wraps the generation id in the release log line for consistency with the stack name beside it. Adds coverage for gaps a QA pass identified: - Release endpoint refuses a viewer and a deployer (Admin-only), leaving the generation and its artifacts untouched. - Viewer can still read the generations list, matching the sibling Resources routes. - The predicate the prune routes build reports full-stack rollback holds, not just service-scoped ones, and re-reads per call so a hold taken between plan and delete still gates the delete. - After releasing the current generation, no rollback point is claimed for the stack through any consumer of the current-generation lookup.
217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
/**
|
|
* Audit log route summaries and summary resolution.
|
|
*
|
|
* Maps HTTP method + API path patterns to human-readable action descriptions.
|
|
* Supports exact prefix matching and single-segment wildcard (*) matching.
|
|
*/
|
|
|
|
export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
|
// Stack CRUD
|
|
'POST /stacks': 'Created stack',
|
|
'DELETE /stacks': 'Deleted stack',
|
|
'PUT /stacks/*/env': 'Updated stack env file',
|
|
'PUT /stacks': 'Updated stack file',
|
|
|
|
// Stack lifecycle
|
|
'POST /stacks/*/deploy': 'Deployed stack',
|
|
'POST /stacks/*/down': 'Took stack down',
|
|
'POST /stacks/*/start': 'Started stack',
|
|
'POST /stacks/*/stop': 'Stopped stack',
|
|
'POST /stacks/*/restart': 'Restarted stack',
|
|
|
|
// Per-service lifecycle (resourceName = stack name; service name is in the path column)
|
|
'POST /stacks/*/services/*/start': 'Started stack service',
|
|
'POST /stacks/*/services/*/stop': 'Stopped stack service',
|
|
'POST /stacks/*/services/*/restart': 'Restarted stack service',
|
|
|
|
'POST /stacks/*/update': 'Updated stack images',
|
|
'POST /stacks/*/rollback': 'Rolled back stack',
|
|
|
|
// Container operations
|
|
'POST /containers/*/start': 'Started container',
|
|
'POST /containers/*/stop': 'Stopped container',
|
|
'POST /containers/*/restart': 'Restarted container',
|
|
|
|
// System operations
|
|
'POST /system/prune': 'Pruned system resources',
|
|
'POST /system/prune/orphans': 'Pruned orphan containers',
|
|
'POST /system/prune/plan': 'Built prune plan',
|
|
'POST /system/prune/system': 'Pruned system resources',
|
|
'POST /system/images/delete': 'Deleted images',
|
|
'POST /system/volumes/delete': 'Deleted volumes',
|
|
'POST /system/networks/delete': 'Deleted networks',
|
|
'POST /system/rollback/generations/*/release': 'Released rollback protection',
|
|
'POST /system/networks': 'Created network',
|
|
'POST /system/console-token': 'Generated console token',
|
|
'POST /system/reapply-compose': 'Triggered compose reapply',
|
|
|
|
// Node management
|
|
'POST /nodes': 'Added node',
|
|
'PUT /nodes': 'Updated node',
|
|
'DELETE /nodes': 'Deleted node',
|
|
'POST /nodes/*/cordon': 'Cordoned node',
|
|
'POST /nodes/*/uncordon': 'Uncordoned node',
|
|
|
|
// User management
|
|
'POST /users': 'Created user',
|
|
'DELETE /users': 'Deleted user',
|
|
'PUT /users': 'Updated user',
|
|
'POST /users/*/mfa/reset': 'Reset two-factor authentication',
|
|
'POST /users/*/roles': 'Assigned role',
|
|
'DELETE /users/*/roles': 'Removed role assignment',
|
|
|
|
// Auth
|
|
'PUT /auth/password': 'Changed password',
|
|
'POST /auth/generate-node-token': 'Generated node token',
|
|
|
|
// License
|
|
'POST /license/activate': 'Activated license',
|
|
'POST /license/deactivate': 'Deactivated license',
|
|
|
|
// Notifications & Agents
|
|
'POST /agents': 'Updated notification agent',
|
|
'POST /notifications/test': 'Tested notification',
|
|
'POST /notification-routes': 'Created notification route',
|
|
'PUT /notification-routes': 'Updated notification route',
|
|
'DELETE /notification-routes': 'Deleted notification route',
|
|
'POST /notification-routes/*/test': 'Tested notification route',
|
|
'POST /notification-suppression-rules': 'Created notification suppression rule',
|
|
'PUT /notification-suppression-rules': 'Updated notification suppression rule',
|
|
'DELETE /notification-suppression-rules': 'Deleted notification suppression rule',
|
|
|
|
// Webhooks
|
|
'POST /webhooks': 'Created webhook',
|
|
'PUT /webhooks': 'Updated webhook',
|
|
'DELETE /webhooks': 'Deleted webhook',
|
|
|
|
// Settings
|
|
'POST /settings': 'Updated settings',
|
|
'PATCH /settings': 'Updated settings',
|
|
|
|
// Fleet
|
|
'POST /fleet/snapshots': 'Created fleet backup',
|
|
'DELETE /fleet/snapshots': 'Deleted fleet backup',
|
|
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
|
|
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
|
'POST /fleet/nodes/*/reapply-compose': 'Triggered fleet node compose reapply',
|
|
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
|
'POST /fleet/role/reanchor': 'Re-anchored fleet replica',
|
|
'POST /fleet/role/demote': 'Demoted fleet replica to control',
|
|
|
|
// Cloud backup
|
|
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
|
'POST /cloud-backup/test': 'Tested cloud backup connection',
|
|
'POST /cloud-backup/provision': 'Provisioned Recovery Vault',
|
|
'POST /cloud-backup/upload': 'Uploaded snapshot to cloud',
|
|
'DELETE /cloud-backup/object': 'Deleted cloud snapshot',
|
|
|
|
// SSO
|
|
'PUT /sso/config': 'Updated SSO configuration',
|
|
'DELETE /sso/config': 'Deleted SSO configuration',
|
|
'POST /sso/config/*/test': 'Tested SSO configuration',
|
|
'PUT /sso/auth-mode': 'Updated authentication mode',
|
|
|
|
// API tokens
|
|
'POST /api-tokens': 'Created API token',
|
|
'DELETE /api-tokens': 'Revoked API token',
|
|
|
|
// Scheduled tasks
|
|
'POST /scheduled-tasks/*/run': 'Triggered scheduled task',
|
|
'POST /scheduled-tasks': 'Created scheduled task',
|
|
'PUT /scheduled-tasks': 'Updated scheduled task',
|
|
'DELETE /scheduled-tasks': 'Deleted scheduled task',
|
|
'PATCH /scheduled-tasks': 'Toggled scheduled task',
|
|
|
|
// Registries
|
|
'POST /registries': 'Created registry credential',
|
|
'PUT /registries': 'Updated registry credential',
|
|
'DELETE /registries': 'Deleted registry credential',
|
|
|
|
// Labels
|
|
'POST /labels': 'Created label',
|
|
'PUT /labels': 'Updated label',
|
|
'DELETE /labels': 'Deleted label',
|
|
'POST /labels/*/action': 'Executed label action',
|
|
'PUT /stacks/*/labels': 'Updated stack labels',
|
|
|
|
// Templates
|
|
'POST /templates/deploy': 'Deployed template',
|
|
|
|
// Auto-update
|
|
'POST /auto-update/execute': 'Executed auto-update',
|
|
|
|
// Blueprints (Federation pin)
|
|
'PUT /blueprints/*/pin': 'Updated blueprint pin',
|
|
|
|
// Fleet secrets
|
|
'POST /secrets': 'Created secret',
|
|
'PUT /secrets': 'Updated secret',
|
|
'DELETE /secrets': 'Deleted secret',
|
|
'POST /secrets/*/import-from-stack': 'Imported env into secret',
|
|
'POST /secrets/*/push/preview': 'Previewed secret push',
|
|
'POST /secrets/*/push': 'Pushed secret',
|
|
};
|
|
|
|
// Pre-sorted at module load: most specific patterns (by segment count) first.
|
|
const SORTED_PATTERNS = Object.entries(AUDIT_ROUTE_SUMMARIES)
|
|
.sort((a, b) => b[0].split('/').length - a[0].split('/').length);
|
|
|
|
/**
|
|
* Resolve a human-readable summary for an audit log entry.
|
|
*
|
|
* Tries wildcard patterns first (most specific by segment count), then
|
|
* falls back to prefix matching. Returns a generic method+path string
|
|
* if no pattern matches.
|
|
*/
|
|
export function getAuditSummary(method: string, apiPath: string, statusCode?: number): string {
|
|
const normalized = apiPath.replace(/^\//, '');
|
|
// Do not claim a successful prune when the request was rejected or blocked.
|
|
if (typeof statusCode === 'number' && statusCode >= 400) {
|
|
if (method === 'POST' && normalized.startsWith('system/prune/system')) {
|
|
if (statusCode === 409) return 'Prune blocked: plan stale';
|
|
if (statusCode === 400) return 'Prune request rejected';
|
|
return `Prune failed (${statusCode})`;
|
|
}
|
|
if (method === 'POST' && normalized.startsWith('system/prune/plan')) {
|
|
return `Prune plan failed (${statusCode})`;
|
|
}
|
|
}
|
|
const normalizedSegments = normalized.split('/');
|
|
|
|
for (const [pattern, summary] of SORTED_PATTERNS) {
|
|
const spaceIdx = pattern.indexOf(' ');
|
|
const pMethod = pattern.slice(0, spaceIdx);
|
|
const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, '');
|
|
if (method !== pMethod) continue;
|
|
|
|
const patternSegments = pPath.split('/');
|
|
const hasWildcard = patternSegments.includes('*');
|
|
|
|
if (hasWildcard) {
|
|
// Wildcard matching: pattern segments must not exceed actual segments
|
|
if (patternSegments.length > normalizedSegments.length) continue;
|
|
let match = true;
|
|
let resourceName = '';
|
|
for (let i = 0; i < patternSegments.length; i++) {
|
|
if (patternSegments[i] === '*') {
|
|
resourceName = resourceName || normalizedSegments[i];
|
|
} else if (patternSegments[i] !== normalizedSegments[i]) {
|
|
match = false;
|
|
break;
|
|
}
|
|
}
|
|
if (match) {
|
|
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
|
}
|
|
} else {
|
|
// Prefix matching (original behavior)
|
|
if (normalized.startsWith(pPath)) {
|
|
const rest = normalized.slice(pPath.length).replace(/^\//, '');
|
|
const resourceName = rest.split('/')[0];
|
|
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
|
}
|
|
}
|
|
}
|
|
return `${method} /api/${normalized}`;
|
|
}
|