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;