fix(db): Renumber workflow review migration to 1784000000048

Master now uses 0047 for OAuth grant scope backfill.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sandra Zollner 2026-07-14 16:54:58 +02:00
parent 615b0a0707
commit df9aa6efe4
No known key found for this signature in database
13 changed files with 124 additions and 9 deletions

View File

@ -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" {

View File

@ -87,6 +87,7 @@ erDiagram
varchar clientId FK
bigint grantedAt
integer id
json scope
uuid userId FK
}
```

View File

@ -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" {

View File

@ -232,6 +232,7 @@ erDiagram
varchar clientId FK
bigint grantedAt
integer id
json scope
uuid userId FK
}
"public.project" {

View File

@ -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" {

View File

@ -92,6 +92,7 @@ erDiagram
varchar clientId FK
bigint grantedAt
INTEGER id
TEXT scope
varchar userId FK
}
```

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
@ -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" {

View File

@ -238,6 +238,7 @@ erDiagram
varchar clientId FK
bigint grantedAt
INTEGER id
TEXT scope
varchar userId FK
}
"project" {

View File

@ -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<string[]>(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}`);
}
}

View File

@ -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);

View File

@ -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,
];

View File

@ -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 };

View File

@ -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;