Root cause
Authentication & access control introduced
feat: Add comprehensive workflow versioning and rollback system with automatic backup (#359)
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Jul 14, 2026 in
czlonkowski/n8n-mcpAll 11 findings in this repository
In ENABLE_MULTI_TENANT deployments the AI-written workflow versioning service looked versions up by workflowId or versionId alone and never checked that the row belonged to the requesting n8n instance. Stored snapshots keep full node definitions, including credential references and authorization headers, so one tenant could read, roll back or delete another tenant's version history. Version 2.56.1 scopes every operation to a tenant key derived from the n8n API URL and API key.
workflow-versioning-service.ts resolved versions through caller-supplied ids and never consulted the instance the request came from, so the tenant boundary existed in configuration but not in the query. The fix adds an instance_id column, derives it through getInstanceScopeId, filters read, list, delete, rollback and prune operations by it, and drops the global truncate.
Root cause
feat: Add comprehensive workflow versioning and rollback system with automatic backup (#359)
AI-assisted change: Claude
Fix
The patch derives a hashed tenant key from each n8n API URL and API key, stores it on workflow-version rows, filters all version operations by it, purges legacy unscoped backups, and removes global truncate.
Fix by Romuald Członkowski · no AI marker found
Case facts
@@ -0,0 +1,460 @@+/**+ * Workflow Versioning Service+ *+ * Provides workflow backup, versioning, rollback, and cleanup capabilities.+ * Automatically prunes to 10 versions per workflow to prevent memory leaks.+ */++import { NodeRepository } from '../database/node-repository';+import { N8nApiClient } from './n8n-api-client';+import { WorkflowValidator } from './workflow-validator';+import { EnhancedConfigValidator } from './enhanced-config-validator';++export interface WorkflowVersion {+ id: number;+ workflowId: string;+ versionNumber: number;+ workflowName: string;+ workflowSnapshot: any;+ trigger: 'partial_update' | 'full_update' | 'autofix';+ operations?: any[];+ fixTypes?: string[];+ metadata?: any;+ createdAt: string;+}++export interface VersionInfo {+ id: number;+ workflowId: string;+ versionNumber: number;+ workflowName: string;+ trigger: string;+ operationCount?: number;+ fixTypesApplied?: string[];+ createdAt: string;+ size: number; // Size in bytes+}++export interface RestoreResult {+ success: boolean;+ message: string;+ workflowId: string;+ fromVersion?: number;+ toVersionId: number;+ backupCreated: boolean;+ backupVersionId?: number;+ validationErrors?: string[];+}++export interface BackupResult {+ versionId: number;+ versionNumber: number;+ pruned: number;+ message: string;+}++export interface StorageStats {+ totalVersions: number;+ totalSize: number;+ totalSizeFormatted: string;+ byWorkflow: WorkflowStorageInfo[];+}++export interface WorkflowStorageInfo {+ workflowId: string;+ workflowName: string;+ versionCount: number;+ totalSize: number;+ totalSizeFormatted: string;+ lastBackup: string;+}++export interface VersionDiff {+ versionId1: number;+ versionId2: number;+ version1Number: number;+ version2Number: number;+ addedNodes: string[];+ removedNodes: string[];+ modifiedNodes: string[];+ connectionChanges: number;+ settingChanges: any;+}++/**+ * Workflow Versioning Service+ */+export class WorkflowVersioningService {+ private readonly DEFAULT_MAX_VERSIONS = 10;+@@ -462,5 +462,59 @@ Examples: } } }+ },+ {+ name: 'n8n_workflow_versions',+ description: `Manage workflow version history, rollback, and cleanup. Six modes:+- list: Show version history for a workflow+- get: Get details of specific version+- rollback: Restore workflow to previous version (creates backup first)+- delete: Delete specific version or all versions for a workflow+- prune: Manually trigger pruning to keep N most recent versions+- truncate: Delete ALL versions for ALL workflows (requires confirmation)`,+ inputSchema: {+ type: 'object',+ properties: {+ mode: {+ type: 'string',+ enum: ['list', 'get', 'rollback', 'delete', 'prune', 'truncate'],+ description: 'Operation mode'+ },+ workflowId: {+ type: 'string',+ description: 'Workflow ID (required for list, rollback, delete, prune)'+ },+ versionId: {+ type: 'number',+ description: 'Version ID (required for get mode and single version delete, optional for rollback)'+ },+ limit: {+ type: 'number',+ default: 10,+ description: 'Max versions to return in list mode'+ },+ validateBefore: {+ type: 'boolean',+ default: true,+ description: 'Validate workflow structure before rollback'+ },+ deleteAll: {+ type: 'boolean',+ default: false,+ description: 'Delete all versions for workflow (delete mode only)'+ },+ maxVersions: {+ type: 'number',+ default: 10,+ description: 'Keep N most recent versions (prune mode only)'+ },+ confirmTruncate: {+ type: 'boolean',+ default: false,+ description: 'REQUIRED: Must be true to truncate all versions (truncate mode only)'+ }+ },+ required: ['mode']+ } } ];@@ -1009,10 +1009,10 @@ export class N8NDocumentationMCPServer { return n8nHandlers.handleGetWorkflowMinimal(args, this.instanceContext); case 'n8n_update_full_workflow': this.validateToolParams(name, args, ['id']);- return n8nHandlers.handleUpdateWorkflow(args, this.instanceContext);+ return n8nHandlers.handleUpdateWorkflow(args, this.repository!, this.instanceContext); case 'n8n_update_partial_workflow': this.validateToolParams(name, args, ['id', 'operations']);- return handleUpdatePartialWorkflow(args, this.instanceContext);+ return handleUpdatePartialWorkflow(args, this.repository!, this.instanceContext); case 'n8n_delete_workflow': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleDeleteWorkflow(args, this.instanceContext);@@ -1050,7 +1050,10 @@ export class N8NDocumentationMCPServer { case 'n8n_diagnostic': // No required parameters return n8nHandlers.handleDiagnostic({ params: { arguments: args } }, this.instanceContext);- + case 'n8n_workflow_versions':+ this.validateToolParams(name, args, ['mode']);+ return n8nHandlers.handleWorkflowVersions(args, this.repository!, this.instanceContext);+ default: throw new Error(`Unknown tool: ${name}`); }diff --git a/src/database/migrations/add-workflow-versions-instance-id.ts b/src/database/migrations/add-workflow-versions-instance-id.tsnew file mode 100644index 0000000..f688b6f--- /dev/null+++ b/src/database/migrations/add-workflow-versions-instance-id.ts@@ -0,0 +1,88 @@+/**+ * Migration: add tenant scoping (instance_id) to workflow_versions.+ *+ * Fixes GHSA-j6r7-6fhx-77wx: the workflow_versions table had no tenant+ * column, so in multi-tenant deployments any tenant could read/delete other+ * tenants' version backups by enumerating sequential version ids.+ *+ * File-based databases do not re-run schema.sql at startup, so this runs at+ * NodeRepository init to upgrade existing databases in place. It is idempotent+ * (guarded by PRAGMA table_info) and a no-op once the column exists.+ *+ * Pre-fix rows have no known tenant and were cross-tenant-readable while+ * vulnerable, so they are purged: when the column is missing the table is+ * dropped and recreated rather than backfilled. Dropping also lets us fix the+ * UNIQUE constraint (now scoped by instance_id), which SQLite cannot ALTER.+ *+ * Only the workflow_versions table is touched; the nodes table (including+ * community nodes) is never affected.+ */++import { DatabaseAdapter } from '../database-adapter';+import { logger } from '../../utils/logger';++// Canonical DDL — keep in sync with src/database/schema.sql.+const CREATE_TABLE = `+ CREATE TABLE IF NOT EXISTS workflow_versions (+ id INTEGER PRIMARY KEY AUTOINCREMENT,+ instance_id TEXT NOT NULL DEFAULT '',+ workflow_id TEXT NOT NULL,+ version_number INTEGER NOT NULL,+ workflow_name TEXT NOT NULL,+ workflow_snapshot TEXT NOT NULL,+ trigger TEXT NOT NULL CHECK(trigger IN (+ 'partial_update',+ 'full_update',+ 'autofix'+ )),+ operations TEXT,+ fix_types TEXT,+ metadata TEXT,+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,+ UNIQUE(instance_id, workflow_id, version_number)+ );+`;++const CREATE_INDEXES = `+ CREATE INDEX IF NOT EXISTS idx_workflow_versions_instance ON workflow_versions(instance_id, workflow_id);+ CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow_id ON workflow_versions(workflow_id);+ CREATE INDEX IF NOT EXISTS idx_workflow_versions_created_at ON workflow_versions(created_at);+ CREATE INDEX IF NOT EXISTS idx_workflow_versions_trigger ON workflow_versions(trigger);+`;++/**+ * Ensure the workflow_versions table is tenant-scoped. Safe to call on every+ * startup. Returns true if a schema change was applied.+ */+export function migrateWorkflowVersionsInstanceId(db: DatabaseAdapter): boolean {+ try {+ const columns = db.prepare('PRAGMA table_info(workflow_versions)').all() as Array<{ name: string }>;+ const tableExists = columns.length > 0;+ const hasInstanceId = columns.some((col) => col.name === 'instance_id');++ if (tableExists && hasInstanceId) {+ // Already migrated.+ return false;+ }++ // Drop (purging legacy, un-tenanted rows) and recreate with the new schema.+ db.exec(`+ DROP TABLE IF EXISTS workflow_versions;+ ${CREATE_TABLE}+ ${CREATE_INDEXES}+ `);++ logger.info(+ tableExists+ ? 'Migrated workflow_versions: added instance_id tenant scoping (legacy version backups purged)'+ : 'Created workflow_versions table with instance_id tenant scoping'+ );+ return true;+ } catch (error) {+ // Tolerate read-only databases and other failures: log and continue so a+ // read-only deployment still starts. Tenant-scoped queries assume the+ // column exists, which holds for any writable versioning database.+ logger.warn('Could not apply workflow_versions instance_id migration', { error });+ return false;+ }+}diff --git a/src/database/node-repository.ts b/src/database/node-repository.tsindex 367d450..c4ce0d6 100644--- a/src/database/node-repository.ts+++ b/src/database/node-repository.ts@@ -2,6 +2,11 @@ import { DatabaseAdapter } from './database-adapter'; import { ParsedNode } from '../parsers/node-parser'; import { SQLiteStorageService } from '../services/sqlite-storage-service'; import { NodeTypeNormalizer } from '../utils/node-type-normalizer';+import { logger } from '../utils/logger';++// Default retention window for workflow version backups (days). Configurable+// via WORKFLOW_VERSION_RETENTION_DAYS; set to 0 to disable age-based pruning.+const DEFAULT_WORKFLOW_VERSION_RETENTION_DAYS = 30; /** * Community node extension fields@@ -28,6 +33,31 @@ export class NodeRepository { this.db = dbOrService; }++ /**+ * Age-based housekeeping: remove version backups past the retention window.+ * Called once during database initialization. Internal maintenance only —+ * not callable by tenants and not tenant-scoped (deterministic retention,+ * not selective destruction).+ */+ pruneExpiredWorkflowVersions(): void {+ const days = parseInt(+ process.env.WORKFLOW_VERSION_RETENTION_DAYS || String(DEFAULT_WORKFLOW_VERSION_RETENTION_DAYS),+ 10+ );+ if (!Number.isFinite(days) || days <= 0) {+ return; // Retention disabled.+ }+ try {+ const cutoffIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();+ const removed = this.deleteWorkflowVersionsOlderThan(cutoffIso);+ if (removed > 0) {+ logger.info(`Pruned ${removed} workflow version backup(s) older than ${days} days`);+ }+ } catch (error) {+ logger.warn('Could not prune expired workflow versions', { error });+ }+ } /** * Save node with proper JSON serialization@@ -1058,10 +1088,16 @@ export class NodeRepository { // Workflow Versioning Methods // ======================================== + // All workflow_versions queries are scoped by instance_id to isolate+ // tenants in multi-tenant deployments (GHSA-j6r7-6fhx-77wx). instanceId is+ // a required, derived tenant key (see getInstanceScopeId); '' is the single+ // logical tenant for single-user / stdio deployments.+ /** * Create a new workflow version (backup before modification) */ createWorkflowVersion(data: {+ instanceId: string; workflowId: string; versionNumber: number; workflowName: string;@@ -1073,12 +1109,13 @@ export class NodeRepository { }): number { const stmt = this.db.prepare(` INSERT INTO workflow_versions (- workflow_id, version_number, workflow_name, workflow_snapshot,+ instance_id, workflow_id, version_number, workflow_name, workflow_snapshot, trigger, operations, fix_types, metadata- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run(+ data.instanceId, data.workflowId, data.versionNumber, data.workflowName,@@ -1095,30 +1132,30 @@ export class NodeRepository { /** * Get workflow versions ordered by version number (newest first) */- getWorkflowVersions(workflowId: string, limit?: number): any[] {+ getWorkflowVersions(workflowId: string, instanceId: string, limit?: number): any[] { let sql = ` SELECT * FROM workflow_versions- WHERE workflow_id = ?+ WHERE workflow_id = ? AND instance_id = ? ORDER BY version_number DESC `; if (limit) { sql += ` LIMIT ?`;- const rows = this.db.prepare(sql).all(workflowId, limit) as any[];+ const rows = this.db.prepare(sql).all(workflowId, instanceId, limit) as any[]; return rows.map(row => this.parseWorkflowVersionRow(row)); } - const rows = this.db.prepare(sql).all(workflowId) as any[];+ const rows = this.db.prepare(sql).all(workflowId, instanceId) as any[]; return rows.map(row => this.parseWorkflowVersionRow(row)); } /**- * Get a specific workflow version by ID+ * Get a specific workflow version by ID, scoped to the caller's tenant */- getWorkflowVersion(versionId: number): any | null {+ getWorkflowVersion(versionId: number, instanceId: string): any | null { const row = this.db.prepare(`- SELECT * FROM workflow_versions WHERE id = ?- `).get(versionId) as any;+ SELECT * FROM workflow_versions WHERE id = ? AND instance_id = ?+ `).get(versionId, instanceId) as any; if (!row) return null; return this.parseWorkflowVersionRow(row);@@ -1127,34 +1164,37 @@ export class NodeRepository { /** * Get the latest workflow version for a workflow */- getLatestWorkflowVersion(workflowId: string): any | null {+ getLatestWorkflowVersion(workflowId: string, instanceId: string): any | null { const row = this.db.prepare(` SELECT * FROM workflow_versions- WHERE workflow_id = ?+ WHERE workflow_id = ? AND instance_id = ? ORDER BY version_number DESC LIMIT 1- `).get(workflowId) as any;+ `).get(workflowId, instanceId) as any; if (!row) return null; return this.parseWorkflowVersionRow(row); } /**- * Delete a specific workflow version+ * Delete a specific workflow version, scoped to the caller's tenant.+ * Returns the number of rows deleted (0 if not owned by this tenant). */- deleteWorkflowVersion(versionId: number): void {- this.db.prepare(`- DELETE FROM workflow_versions WHERE id = ?- `).run(versionId);+ deleteWorkflowVersion(versionId: number, instanceId: string): number {+ const result = this.db.prepare(`+ DELETE FROM workflow_versions WHERE id = ? AND instance_id = ?+ `).run(versionId, instanceId);++ return result.changes; } /** * Delete all versions for a specific workflow */- deleteWorkflowVersionsByWorkflowId(workflowId: string): number {+ deleteWorkflowVersionsByWorkflowId(workflowId: string, instanceId: string): number { const result = this.db.prepare(`- DELETE FROM workflow_versions WHERE workflow_id = ?- `).run(workflowId);+ DELETE FROM workflow_versions WHERE workflow_id = ? AND instance_id = ?+ `).run(workflowId, instanceId); return result.changes; }@@ -1163,13 +1203,13 @@ export class NodeRepository { * Prune old workflow versions, keeping only the most recent N versions * Returns number of versions deleted */- pruneWorkflowVersions(workflowId: string, keepCount: number): number {+ pruneWorkflowVersions(workflowId: string, keepCount: number, instanceId: string): number { // Get all versions ordered by version_number DESC const versions = this.db.prepare(` SELECT id FROM workflow_versions- WHERE workflow_id = ?+ WHERE workflow_id = ? AND instance_id = ? ORDER BY version_number DESC- `).all(workflowId) as any[];+ `).all(workflowId, instanceId) as any[]; // If we have fewer versions than keepCount, no pruning needed if (versions.length <= keepCount) {@@ -1193,13 +1233,14 @@ export class NodeRepository { } /**- * Truncate the entire workflow_versions table- * Returns number of rows deleted+ * Delete all version backups older than the given ISO timestamp, across all+ * tenants. Internal age-based retention sweep — deterministic housekeeping+ * that exposes no data and is not callable by tenants. Returns rows deleted. */- truncateWorkflowVersions(): number {+ deleteWorkflowVersionsOlderThan(cutoffIso: string): number { const result = this.db.prepare(`- DELETE FROM workflow_versions- `).run();+ DELETE FROM workflow_versions WHERE created_at < ?+ `).run(cutoffIso); return result.changes; }@@ -1207,27 +1248,27 @@ export class NodeRepository { /** * Get count of versions for a specific workflow */- getWorkflowVersionCount(workflowId: string): number {+ getWorkflowVersionCount(workflowId: string, instanceId: string): number { const result = this.db.prepare(`- SELECT COUNT(*) as count FROM workflow_versions WHERE workflow_id = ?- `).get(workflowId) as any;+ SELECT COUNT(*) as count FROM workflow_versions WHERE workflow_id = ? AND instance_id = ?+ `).get(workflowId, instanceId) as any; return result.count; } /**- * Get storage statistics for workflow versions+ * Get storage statistics for workflow versions, scoped to the caller's tenant */- getVersionStorageStats(): any {+ getVersionStorageStats(instanceId: string): any { // Total versions const totalResult = this.db.prepare(`- SELECT COUNT(*) as count FROM workflow_versions- `).get() as any;+ SELECT COUNT(*) as count FROM workflow_versions WHERE instance_id = ?+ `).get(instanceId) as any; // Total size (approximate - sum of JSON lengths) const sizeResult = this.db.prepare(`- SELECT SUM(LENGTH(workflow_snapshot)) as total_size FROM workflow_versions- `).get() as any;+ SELECT SUM(LENGTH(workflow_snapshot)) as total_size FROM workflow_versions WHERE instance_id = ?+ `).get(instanceId) as any; // Per-workflow breakdown const byWorkflow = this.db.prepare(`@@ -1238,9 +1279,10 @@ export class NodeRepository { SUM(LENGTH(workflow_snapshot)) as total_size, MAX(created_at) as last_backup FROM workflow_versions+ WHERE instance_id = ? GROUP BY workflow_id ORDER BY version_count DESC- `).all() as any[];+ `).all(instanceId) as any[]; return { totalVersions: totalResult.count,diff --git a/src/database/schema.sql b/src/database/schema.sqlindex b866641..8bcf003 100644--- a/src/database/schema.sql+++ b/src/database/schema.sql@@ -237,6 +237,7 @@ CREATE INDEX IF NOT EXISTS idx_prop_changes_auto ON version_property_changes(aut -- Auto-prunes to 10 versions per workflow to prevent memory leaks CREATE TABLE IF NOT EXISTS workflow_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT,+ instance_id TEXT NOT NULL DEFAULT '', -- Tenant scope (see getInstanceScopeId); '' = single-tenant workflow_id TEXT NOT NULL, -- n8n workflow ID version_number INTEGER NOT NULL, -- Incremental version number (1, 2, 3...) workflow_name TEXT NOT NULL, -- Workflow name at time of backup@@ -250,10 +251,11 @@ CREATE TABLE IF NOT EXISTS workflow_versions ( fix_types TEXT, -- JSON array of fix types (if autofix) metadata TEXT, -- Additional context (JSON) created_at DATETIME DEFAULT CURRENT_TIMESTAMP,- UNIQUE(workflow_id, version_number)+ UNIQUE(instance_id, workflow_id, version_number) ); -- Indexes for workflow version queries+CREATE INDEX IF NOT EXISTS idx_workflow_versions_instance ON workflow_versions(instance_id, workflow_id); CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow_id ON workflow_versions(workflow_id); CREATE INDEX IF NOT EXISTS idx_workflow_versions_created_at ON workflow_versions(created_at); CREATE INDEX IF NOT EXISTS idx_workflow_versions_trigger ON workflow_versions(trigger);\ No newline at end of fileAI-assisted change 756df51addd4677a3ea205e8607097c532904243c27a98c5f3ad9992b1b1c019 · Fix c23ccd8ec7f4c64a587fafb5e1868be7354c4d6978ceb035fa385ff8a57c56fa