From df9aa6efe415d435e875e29fe05a26005f5f6f42 Mon Sep 17 00:00:00 2001 From: Sandra Zollner Date: Tue, 14 Jul 2026 16:54:58 +0200 Subject: [PATCH] fix(db): Renumber workflow review migration to 1784000000048 Master now uses 0047 for OAuth grant scope backfill. Co-authored-by: Cursor --- docs/generated/postgres-schema/README.md | 3 +- .../postgres-schema/public.oauth_clients.md | 1 + .../public.oauth_user_consents.md | 3 + docs/generated/postgres-schema/public.user.md | 1 + docs/generated/sqlite-schema/README.md | 3 +- docs/generated/sqlite-schema/oauth_clients.md | 1 + .../sqlite-schema/oauth_user_consents.md | 4 +- docs/generated/sqlite-schema/user.md | 1 + ...0047-BackfillPreScopingOAuthGrantScopes.ts | 100 ++++++++++++++++++ ...0048-CreateWorkflowReviewRequestTables.ts} | 2 +- .../db/src/migrations/postgresdb/index.ts | 6 +- .../@n8n/db/src/migrations/sqlite/index.ts | 6 +- ...te-workflow-review-request-tables.test.ts} | 2 +- 13 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1784000000047-BackfillPreScopingOAuthGrantScopes.ts rename packages/@n8n/db/src/migrations/common/{1784000000047-CreateWorkflowReviewRequestTables.ts => 1784000000048-CreateWorkflowReviewRequestTables.ts} (98%) rename packages/cli/test/migration/{1784000000047-create-workflow-review-request-tables.test.ts => 1784000000048-create-workflow-review-request-tables.test.ts} (99%) diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index c5066180da2..dda6f04e8d7 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -84,7 +84,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.oauth_authorization_codes](public.oauth_authorization_codes.md) | 13 | | BASE TABLE | | [public.oauth_clients](public.oauth_clients.md) | 9 | | BASE TABLE | | [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 7 | | BASE TABLE | -| [public.oauth_user_consents](public.oauth_user_consents.md) | 4 | | BASE TABLE | +| [public.oauth_user_consents](public.oauth_user_consents.md) | 5 | | BASE TABLE | | [public.processed_data](public.processed_data.md) | 5 | | BASE TABLE | | [public.project](public.project.md) | 9 | | BASE TABLE | | [public.project_relation](public.project_relation.md) | 5 | | BASE TABLE | @@ -1061,6 +1061,7 @@ erDiagram varchar clientId FK bigint grantedAt integer id + json scope uuid userId FK } "public.processed_data" { diff --git a/docs/generated/postgres-schema/public.oauth_clients.md b/docs/generated/postgres-schema/public.oauth_clients.md index 048d6174523..df8e99d1fce 100644 --- a/docs/generated/postgres-schema/public.oauth_clients.md +++ b/docs/generated/postgres-schema/public.oauth_clients.md @@ -87,6 +87,7 @@ erDiagram varchar clientId FK bigint grantedAt integer id + json scope uuid userId FK } ``` diff --git a/docs/generated/postgres-schema/public.oauth_user_consents.md b/docs/generated/postgres-schema/public.oauth_user_consents.md index 941d21584a1..78451d89ecc 100644 --- a/docs/generated/postgres-schema/public.oauth_user_consents.md +++ b/docs/generated/postgres-schema/public.oauth_user_consents.md @@ -7,6 +7,7 @@ | clientId | varchar | | false | | [public.oauth_clients](public.oauth_clients.md) | | | grantedAt | bigint | | false | | | Unix timestamp in milliseconds | | id | integer | | false | | | | +| scope | json | | false | | | OAuth scopes granted on the consent screen | | userId | uuid | | false | | [public.user](public.user.md) | | ## Constraints @@ -20,6 +21,7 @@ | oauth_user_consents_clientId_not_null | n | NOT NULL "clientId" | | oauth_user_consents_grantedAt_not_null | n | NOT NULL "grantedAt" | | oauth_user_consents_id_not_null | n | NOT NULL id | +| oauth_user_consents_scope_not_null | n | NOT NULL scope | | oauth_user_consents_userId_not_null | n | NOT NULL "userId" | ## Indexes @@ -41,6 +43,7 @@ erDiagram varchar clientId FK bigint grantedAt integer id + json scope uuid userId FK } "public.oauth_clients" { diff --git a/docs/generated/postgres-schema/public.user.md b/docs/generated/postgres-schema/public.user.md index 79b744745cb..a22205ef97c 100644 --- a/docs/generated/postgres-schema/public.user.md +++ b/docs/generated/postgres-schema/public.user.md @@ -232,6 +232,7 @@ erDiagram varchar clientId FK bigint grantedAt integer id + json scope uuid userId FK } "public.project" { diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 5e2d45ce664..b73faf16106 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -84,7 +84,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [oauth_authorization_codes](oauth_authorization_codes.md) | 13 | | table | | [oauth_clients](oauth_clients.md) | 9 | | table | | [oauth_refresh_tokens](oauth_refresh_tokens.md) | 7 | | table | -| [oauth_user_consents](oauth_user_consents.md) | 4 | | table | +| [oauth_user_consents](oauth_user_consents.md) | 5 | | table | | [processed_data](processed_data.md) | 5 | | table | | [project](project.md) | 9 | | table | | [project_relation](project_relation.md) | 5 | | table | @@ -1048,6 +1048,7 @@ erDiagram varchar clientId FK bigint grantedAt INTEGER id + TEXT scope varchar userId FK } "processed_data" { diff --git a/docs/generated/sqlite-schema/oauth_clients.md b/docs/generated/sqlite-schema/oauth_clients.md index d7f41c6b15e..aff09badeb2 100644 --- a/docs/generated/sqlite-schema/oauth_clients.md +++ b/docs/generated/sqlite-schema/oauth_clients.md @@ -92,6 +92,7 @@ erDiagram varchar clientId FK bigint grantedAt INTEGER id + TEXT scope varchar userId FK } ``` diff --git a/docs/generated/sqlite-schema/oauth_user_consents.md b/docs/generated/sqlite-schema/oauth_user_consents.md index 9488f8c1ffd..61a4d9e7df2 100644 --- a/docs/generated/sqlite-schema/oauth_user_consents.md +++ b/docs/generated/sqlite-schema/oauth_user_consents.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "oauth_user_consents" ("id" integer PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "clientId" varchar NOT NULL, "grantedAt" bigint NOT NULL, CONSTRAINT "UQ_083721d99ce8db4033e2958ebb4" UNIQUE ("userId", "clientId"), CONSTRAINT "FK_a651acea2f6c97f8c4514935486" FOREIGN KEY ("clientId") REFERENCES "oauth_clients" ("id") ON DELETE CASCADE, CONSTRAINT "FK_21e6c3c2d78a097478fae6aaefa" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE) +CREATE TABLE "oauth_user_consents" ("id" integer PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "clientId" varchar NOT NULL, "grantedAt" bigint NOT NULL, "scope" text NOT NULL, CONSTRAINT "UQ_083721d99ce8db4033e2958ebb4" UNIQUE ("userId", "clientId"), CONSTRAINT "FK_a651acea2f6c97f8c4514935486" FOREIGN KEY ("clientId") REFERENCES "oauth_clients" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_21e6c3c2d78a097478fae6aaefa" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -18,6 +18,7 @@ CREATE TABLE "oauth_user_consents" ("id" integer PRIMARY KEY NOT NULL, "userId" | clientId | varchar | | false | | [oauth_clients](oauth_clients.md) | | | grantedAt | bigint | | false | | | | | id | INTEGER | | false | | | | +| scope | TEXT | | false | | | | | userId | varchar | | false | | [user](user.md) | | ## Constraints @@ -47,6 +48,7 @@ erDiagram varchar clientId FK bigint grantedAt INTEGER id + TEXT scope varchar userId FK } "oauth_clients" { diff --git a/docs/generated/sqlite-schema/user.md b/docs/generated/sqlite-schema/user.md index 18020eafc66..913f47e0ea9 100644 --- a/docs/generated/sqlite-schema/user.md +++ b/docs/generated/sqlite-schema/user.md @@ -238,6 +238,7 @@ erDiagram varchar clientId FK bigint grantedAt INTEGER id + TEXT scope varchar userId FK } "project" { diff --git a/packages/@n8n/db/src/migrations/common/1784000000047-BackfillPreScopingOAuthGrantScopes.ts b/packages/@n8n/db/src/migrations/common/1784000000047-BackfillPreScopingOAuthGrantScopes.ts new file mode 100644 index 00000000000..8c23d4cf761 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000047-BackfillPreScopingOAuthGrantScopes.ts @@ -0,0 +1,100 @@ +import type { MigrationContext, IrreversibleMigration } from '../migration-types'; + +// Backfill value written to the `scope` column by migration 1784000000026 for +// grants that predate scoping. No runtime code ever wrote this value, so it +// provably identifies a grant made under the old full-delegation contract. +const LEGACY_SENTINEL = ['tool:listWorkflows', 'tool:getWorkflowDetails']; + +// The instance MCP scope set at the moment scoping shipped. Pre-scoping grants +// were full user delegations, so they get exactly this frozen set — scopes +// added in later releases require a fresh consent. Inlined because migrations +// are snapshots and must not import runtime code. +const PRE_SCOPING_FULL_SCOPES = [ + 'workflow:read', + 'workflow:write', + 'workflow:execute', + 'execution:read', + 'credential:read', + 'dataTable:read', + 'dataTable:write', + 'project:read', + 'tag:read', +]; + +/** + * Moves OAuth grants that predate scoping onto explicit scopes so no runtime + * grandfathering logic is needed: + * - `oauth_user_consents` gets a NOT NULL `scope` column, with existing + * consents backfilled to the frozen launch scope set; + * - sentinel-scoped refresh tokens and authorization codes get the same set; + * - all access tokens are deleted. They are 1-hour JWTs whose `scope` claim + * cannot be rewritten; deleting the rows makes verification fail so clients + * transparently refresh, and the refreshed tokens carry a real scope claim. + */ +export class BackfillPreScopingOAuthGrantScopes1784000000047 implements IrreversibleMigration { + async up(context: MigrationContext) { + await this.addConsentScopeColumn(context); + await this.rewriteSentinelRows(context, 'oauth_refresh_tokens', 'token'); + await this.rewriteSentinelRows(context, 'oauth_authorization_codes', 'code'); + + const accessTokens = context.escape.tableName('oauth_access_tokens'); + await context.runQuery(`DELETE FROM ${accessTokens}`); + } + + private async addConsentScopeColumn({ + schemaBuilder: { addColumns, addNotNull, column }, + escape, + runQuery, + }: MigrationContext) { + await addColumns( + 'oauth_user_consents', + [column('scope').json.comment('OAuth scopes granted on the consent screen')], + { recreatesOnSqlite: true }, + ); + + const table = escape.tableName('oauth_user_consents'); + const scopeColumn = escape.columnName('scope'); + await runQuery(`UPDATE ${table} SET ${scopeColumn} = :scope WHERE ${scopeColumn} IS NULL`, { + scope: JSON.stringify(PRE_SCOPING_FULL_SCOPES), + }); + + // No default on purpose: an insert that forgets the scope must fail + // instead of silently granting anything. + await addNotNull('oauth_user_consents', 'scope', { recreatesOnSqlite: true }); + } + + private async rewriteSentinelRows( + { escape, runQuery, runInBatches, parseJson, logger, migrationName }: MigrationContext, + tableName: string, + primaryKey: string, + ) { + const table = escape.tableName(tableName); + const scopeColumn = escape.columnName('scope'); + const keyColumn = escape.columnName(primaryKey); + + let rewritten = 0; + // JSON equality is not portable SQL, so rows are compared in JS. The + // ORDER BY keeps LIMIT/OFFSET pages stable while rows are updated mid-scan. + await runInBatches<{ key: string; scope: string | string[] }>( + `SELECT ${keyColumn} AS key, ${scopeColumn} AS scope FROM ${table} ORDER BY ${keyColumn}`, + async (rows) => { + for (const row of rows) { + const scopes = parseJson(row.scope); + const isSentinel = + Array.isArray(scopes) && + scopes.length === LEGACY_SENTINEL.length && + LEGACY_SENTINEL.every((scope, i) => scopes[i] === scope); + if (!isSentinel) continue; + + await runQuery(`UPDATE ${table} SET ${scopeColumn} = :scope WHERE ${keyColumn} = :key`, { + scope: JSON.stringify(PRE_SCOPING_FULL_SCOPES), + key: row.key, + }); + rewritten++; + } + }, + ); + + logger.info(`[${migrationName}] Rewrote ${rewritten} pre-scoping grants in ${tableName}`); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1784000000047-CreateWorkflowReviewRequestTables.ts b/packages/@n8n/db/src/migrations/common/1784000000048-CreateWorkflowReviewRequestTables.ts similarity index 98% rename from packages/@n8n/db/src/migrations/common/1784000000047-CreateWorkflowReviewRequestTables.ts rename to packages/@n8n/db/src/migrations/common/1784000000048-CreateWorkflowReviewRequestTables.ts index 1a29e5e1c40..33a9a79c1e6 100644 --- a/packages/@n8n/db/src/migrations/common/1784000000047-CreateWorkflowReviewRequestTables.ts +++ b/packages/@n8n/db/src/migrations/common/1784000000048-CreateWorkflowReviewRequestTables.ts @@ -14,7 +14,7 @@ const UQ_WORKFLOW_REQUEST_WORKFLOW = 'workflow_review_request_workflow_request_w const UQ_WORKFLOW_REQUEST_REVIEWERS = 'workflow_review_request_reviewers_request_user'; const UQ_WORKFLOW_REQUEST_AUTHORS = 'workflow_review_request_authors_request_user'; -export class CreateWorkflowReviewRequestTables1784000000047 implements ReversibleMigration { +export class CreateWorkflowReviewRequestTables1784000000048 implements ReversibleMigration { async up(context: MigrationContext) { await this.createRequestTable(context); await this.createWorkflowChildTable(context); diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 34645db3896..5eb15075c9f 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -220,7 +220,8 @@ import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-Crea import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials'; import { AddRecurringCronScheduleKind1784000000045 } from '../common/1784000000045-AddRecurringCronScheduleKind'; import { CreateInstanceAiEventsTable1784000000046 } from '../common/1784000000046-CreateInstanceAiEventsTable'; -import { CreateWorkflowReviewRequestTables1784000000047 } from '../common/1784000000047-CreateWorkflowReviewRequestTables'; +import { BackfillPreScopingOAuthGrantScopes1784000000047 } from '../common/1784000000047-BackfillPreScopingOAuthGrantScopes'; +import { CreateWorkflowReviewRequestTables1784000000048 } from '../common/1784000000048-CreateWorkflowReviewRequestTables'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -446,5 +447,6 @@ export const postgresMigrations: Migration[] = [ AddPartialIndexForGlobalCredentials1784000000044, AddRecurringCronScheduleKind1784000000045, CreateInstanceAiEventsTable1784000000046, - CreateWorkflowReviewRequestTables1784000000047, + BackfillPreScopingOAuthGrantScopes1784000000047, + CreateWorkflowReviewRequestTables1784000000048, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 6496827c3dc..2d869b932d8 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -212,7 +212,8 @@ import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../comm import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables'; import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials'; import { CreateInstanceAiEventsTable1784000000046 } from '../common/1784000000046-CreateInstanceAiEventsTable'; -import { CreateWorkflowReviewRequestTables1784000000047 } from '../common/1784000000047-CreateWorkflowReviewRequestTables'; +import { BackfillPreScopingOAuthGrantScopes1784000000047 } from '../common/1784000000047-BackfillPreScopingOAuthGrantScopes'; +import { CreateWorkflowReviewRequestTables1784000000048 } from '../common/1784000000048-CreateWorkflowReviewRequestTables'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -428,7 +429,8 @@ const sqliteMigrations: Migration[] = [ AddPartialIndexForGlobalCredentials1784000000044, AddRecurringCronScheduleKind1784000000045, CreateInstanceAiEventsTable1784000000046, - CreateWorkflowReviewRequestTables1784000000047, + BackfillPreScopingOAuthGrantScopes1784000000047, + CreateWorkflowReviewRequestTables1784000000048, ]; export { sqliteMigrations }; diff --git a/packages/cli/test/migration/1784000000047-create-workflow-review-request-tables.test.ts b/packages/cli/test/migration/1784000000048-create-workflow-review-request-tables.test.ts similarity index 99% rename from packages/cli/test/migration/1784000000047-create-workflow-review-request-tables.test.ts rename to packages/cli/test/migration/1784000000048-create-workflow-review-request-tables.test.ts index c67a8736cb4..22f3d54b92b 100644 --- a/packages/cli/test/migration/1784000000047-create-workflow-review-request-tables.test.ts +++ b/packages/cli/test/migration/1784000000048-create-workflow-review-request-tables.test.ts @@ -10,7 +10,7 @@ import { DataSource } from '@n8n/typeorm'; import { generateNanoId } from '@n8n/utils/generate-nano-id'; import { randomUUID } from 'node:crypto'; -const MIGRATION_NAME = 'CreateWorkflowReviewRequestTables1784000000047'; +const MIGRATION_NAME = 'CreateWorkflowReviewRequestTables1784000000048'; describe('CreateWorkflowReviewRequestTables Migration', () => { let dataSource: DataSource;