Skip to content

chore(core): Move scopes and roles into database in preparation for custom roles #17226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: master
Choose a base branch
from

Conversation

afitzek
Copy link
Contributor

@afitzek afitzek commented Jul 11, 2025

Summary

We move scopes and roles into the database and use the database as the source of truth for these. This is to
prepare the system for custom roles, which can be stored in the database.

Related Linear tickets, Github issues, and Community forum posts

closes https://linear.app/n8n/issue/PAY-3054/move-role-definitions-into-the-database

Review / Merge checklist

  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with release/backport (if the PR is an urgent fix that needs to be backported)

Copy link

codecov bot commented Jul 11, 2025

❌ 1631 Tests Failed:

Tests completed Failed Passed Skipped
9231 1631 7600 0
View the top 3 failed test(s) by shortest run time
--deleteWorkflowsAndCredentials resets LDAP settings
Stack Traces | 0s run time
QueryFailedError: SQLITE_ERROR: near ":member": syntax error
    at handler (.../n8n/node_modules/.pnpm/@[email protected]_@[email protected][email protected][email protected][email protected]_pg@.../driver/sqlite/SqliteQueryRunner.ts:137:29)
    at replacement (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/trace.js:25:27)
    at Statement.errBack (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/sqlite3.js:15:21)
PATCH /projects/:projectId member management should not add or remove users from a personal project
Stack Traces | 0s run time
QueryFailedError: SQLITE_ERROR: near ":member": syntax error
    at handler (.../n8n/node_modules/.pnpm/@[email protected]_@[email protected][email protected][email protected][email protected]_pg@.../driver/sqlite/SqliteQueryRunner.ts:137:29)
    at replacement (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/trace.js:25:27)
    at Statement.errBack (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/sqlite3.js:15:21)
POST /projects/:projectId/folders should create folder in personal project
Stack Traces | 0s run time
QueryFailedError: SQLITE_ERROR: near ":member": syntax error
    at handler (.../n8n/node_modules/.pnpm/@[email protected]_@[email protected][email protected][email protected][email protected]_pg@.../driver/sqlite/SqliteQueryRunner.ts:137:29)
    at replacement (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/trace.js:25:27)
    at Statement.errBack (.../n8n/node_modules/.pnpm/[email protected]..../sqlite3/lib/sqlite3.js:15:21)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@n8n-assistant n8n-assistant bot added core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team labels Jul 11, 2025
@afitzek afitzek force-pushed the pay-3054-move-roles-into-database branch from 4d9a6d0 to 6602f56 Compare July 16, 2025 08:09
@afitzek afitzek force-pushed the pay-3054-move-roles-into-database branch from 49e3965 to 14920c9 Compare July 29, 2025 09:25
Copy link

bundlemon bot commented Jul 29, 2025

BundleMon

Files added (2)
Status Path Size Limits
WASM Dependencies
tree-sitter-bash.wasm
+181.26KB -
WASM Dependencies
tree-sitter.wasm
+74.47KB -

Total files change +255.73KB

Groups added (2)
Status Path Size Limits
**/*.js
+5.35MB -
**/*.css
+188.05KB -

Final result: ✅

View report in BundleMon website ➡️


Current branch size history

@afitzek afitzek marked this pull request as ready for review July 29, 2025 11:20
@afitzek afitzek requested a review from a team as a code owner July 29, 2025 11:20
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

10 issues found across 26 files • Review in cubic

React with 👍 or 👎 to teach cubic. You can also tag @cubic-dev-ai to give feedback, ask questions, or re-run the review.

systemRole: true,
},
);
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching and ignoring every error hides real migration failures; consider only suppressing duplicate-key violations.

Prompt for AI agents
Address the following comment on packages/@n8n/db/src/migrations/common/1750252139168-LinkRoleToUserTable.ts at line 30:

<comment>Catching and ignoring every error hides real migration failures; consider only suppressing duplicate-key violations.</comment>

<file context>
@@ -0,0 +1,78 @@
+import type { MigrationContext, ReversibleMigration } from &#39;../migration-types&#39;;
+
+/*
+ * This migration
+ */
+
+export class LinkRoleToUserTable1750252139168 implements ReversibleMigration {
+	async up({
+		schemaBuilder: { addForeignKey, addColumns, column },
</file context>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with the bot here 🤣

Copy link
Contributor

@guillaumejacquart guillaumejacquart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some minor comments

@@ -105,7 +105,7 @@ export interface PublicUser {
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
role?: GlobalRole;
role?: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do this on followup PR ?

systemRole: true,
},
);
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with the bot here 🤣

const systemRoleColumn = escape.columnName('systemRole');

// Make sure that the global roles that we need exist
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DO you think it would make sense to have a loop here (of ['global:owner', ''global:admin', 'global:member']) ?

Copy link
Contributor

@guillaumejacquart guillaumejacquart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some additional minor comments

for (const role of ['global:owner', 'global:admin', 'global:member']) {
if (dbType === 'sqlite') {
await runQuery(
`INSERT OR REPLACE INTO ${tableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sqlite supports upsert now: https://sqlite.org/lang_upsert.html
So it could have the same syntax as postgres I think.
Plus currently, your sqlite/mysql query replace in case of conflict, while the postgres one ignore on conflict

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I was able to change the insert queries to all ignore on conflict, but still all 3 db queries are different, the sqlite one needs an INSERT OR REPLACE, which postgres doesn't recognize.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression that Sqlite did not need INSERT OR REPLACE (see examples here): https://sqlite.org/lang_upsert.html#examples

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah you are right, I miss read 🙈

};
return [slug, info.displayName, info.description ?? null] as const;
})
.filter(([slug, displayName, description]) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(completely optional)
I'm questioning the relevance of splitting filter with map, because we need to check existing scope exists in both steps.

What about:

const scopesToSave = ALL_SCOPES.map((slug) => {
			const info = scopeInformation[slug] ?? { displayName: slug, description: null };
			const existing = existingScopes.find(scope => scope.slug === slug);
			
			// Create new scope if doesn't exist
			if (!existing) {
				const newScope = new Scope();
				newScope.slug = slug;
				newScope.displayName = info.displayName;
				newScope.description = info.description ?? null;
				return newScope;
			}
			
			// Update existing scope if needed
			const needsUpdate = existing.displayName !== info.displayName || 
							   existing.description !== (info.description ?? null);
			
			if (needsUpdate) {
				existing.displayName = info.displayName;
				existing.description = info.description ?? null;
				return existing;
			}
			
			return null;
		}).filter(Boolean);

Copy link
Contributor

@guillaumejacquart guillaumejacquart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me

@afitzek afitzek requested a review from a team July 31, 2025 06:18
@afitzek afitzek force-pushed the pay-3054-move-roles-into-database branch 2 times, most recently from f6c05ad to 680c013 Compare July 31, 2025 07:13
Copy link

currents-bot bot commented Jul 31, 2025

E2E Tests: n8n tests passed after 5m 29.4s

🟢 508 · 🔴 0 · ⚪️ 0

View Run Details

Run Details

  • Project: n8n

  • Groups: 1

  • Framework: Currents

  • Run Status: Passed

  • Commit: 680c013

  • Spec files: 105

  • Overall tests: 508

  • Duration: 5m 29.4s

  • Parallelization: 1


This message was posted automatically by currents.dev | Integration Settings

default: false,
name: 'systemRole',
})
systemRole: boolean; // Indicates if the role is managed by the system and cannot be edited
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the comments here to JSDoc so they are discoverable via intellisense?

async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('scope').withColumns(
column('slug').varchar(128).primary.notNull,
column('displayName').text.default(null),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be not null in the entity definition. Probably should be here as well? Same applies to the other migrations

Comment on lines +12 to +14
* slug | Text | Unique identifier of the scope for example: 'project:create'
* displayName | Text | Name used to display in the UI
* description | Text | Text describing the scope in more detail of users
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add these descriptions as comments to the columns with .comment(...). Same applies to the other migrations

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we have 3 separate migrations instead of 1?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to keep the migrations small and simple and specific to do one thing.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd prefer just one, since every migration is extra overhead in instance startup. One migration can also be composed into smaller functions. But I can understand your reasoning as well.

column('slug').varchar(128).primary.notNull,
column('displayName').text.default(null),
column('description').text.default(null),
column('roleType').text.default(null),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a constraint that enforces the valid values for this column?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We opted to not validate the values in the database in there, because we might want to add more values here later on, at which point we would need to do another migration to extend the the type.

return resourceScopes;
}

export const ALL_SCOPES = buildResourceScopes();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having a test case that does expect(ALL_SCOPES).toMatchSnapshot() so a) we have a list of all scopes and b) one needs explicit action if they are changed?

description: null,
};

const existingScope = availableScopes.find((scope) => scope.slug === slug);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This is a linear search. The array is probably not big, but it all adds up. Using e.g. Map for availableScopes would be more approriate

for (const roleNamespace of Object.keys(ALL_ROLES) as Array<keyof typeof ALL_ROLES>) {
const rolesToUpdate = ALL_ROLES[roleNamespace]
.map((role) => {
const existingRole = existingRoles.find((r) => r.slug === role.role);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

await this.scopeRepository.save(scopesToUpdate);
this.logger.info('Scopes updated successfully.');
} else {
this.logger.info('No scopes to update.');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would debug be more approriate here? Not sure if this is needs to be logged on every startup

await this.roleRepository.save(rolesToUpdate);
this.logger.info(`${roleNamespace} roles updated successfully.`);
} else {
this.logger.info(`No ${roleNamespace} roles to update.`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very clean and easy to follow code. Nice job 👏

despairblue
despairblue previously approved these changes Aug 1, 2025
Copy link
Contributor

@despairblue despairblue left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only looked at the migrations and they look solid. 👍🏾

@guillaumejacquart guillaumejacquart dismissed stale reviews from despairblue and themself via 3bdc588 August 1, 2025 15:12
@guillaumejacquart guillaumejacquart force-pushed the pay-3054-move-roles-into-database branch from 680c013 to 3bdc588 Compare August 1, 2025 15:12
@afitzek afitzek force-pushed the pay-3054-move-roles-into-database branch from 99f21b5 to 55e8cc9 Compare August 4, 2025 09:48
Copy link
Collaborator

@tomi tomi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the feedback 💘 LGTM now. Just one question regarding multi-main mode

}
}

async init() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still behave correctly when we have multiple mains starting up simultaneously and all racing to run this?

@afitzek
Copy link
Contributor Author

afitzek commented Aug 4, 2025

Thank you for addressing the feedback 💘 LGTM now. Just one question regarding multi-main mode

Thanks, that is a good question:
I think with multi-main it should still be good, because if all mains are running the same version, they try to run the same query, worst case would be that each main runs the same update, but this shouldn't lead to any problems. On the other hand, if only the leader runs this update, with a zero down time update, there can be multiple different versions running at the same time, so we wouldn't be able to ensure that a leader runs this during start up, we would need to run it on each leader change, which would also lead to multiple executions of the query in the end. But if this would become an issue, I think we could look into synchronizing this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants