diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 32e5cef2d4..b90ad9246c 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -1,6 +1,7 @@ export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list'; export type Resource = | 'workflow' + | 'tag' | 'user' | 'credential' | 'variable' @@ -13,7 +14,8 @@ export type ResourceScope< > = `${R}:${Operations}`; export type WildcardScope = `${Resource}:*` | '*'; -export type WorkflowScope = ResourceScope<'workflow'>; +export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>; +export type TagScope = ResourceScope<'tag'>; export type UserScope = ResourceScope<'user'>; export type CredentialScope = ResourceScope<'credential'>; export type VariableScope = ResourceScope<'variable'>; @@ -25,6 +27,7 @@ export type ExternalSecretStoreScope = ResourceScope< export type Scope = | WorkflowScope + | TagScope | UserScope | CredentialScope | VariableScope diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d74cfcb59..be81f68f33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -99,6 +99,7 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", + "@n8n/permissions": "workspace:*", "@n8n_io/license-sdk": "~2.7.1", "@oclif/command": "^1.8.16", "@oclif/config": "^1.18.17", diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 8bcdb7875f..83170c4f34 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -47,6 +47,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types'; import type { WorkerJobStatusSummary } from './services/orchestration/worker/types'; +import type { Scope } from '@n8n/permissions'; export interface ICredentialsTypeData { [key: string]: CredentialLoadingDetails; @@ -772,6 +773,7 @@ export interface PublicUser { isPending: boolean; hasRecoveryCodesLeft: boolean; globalRole?: Role; + globalScopes?: Scope[]; signInType: AuthProviderType; disabled: boolean; settings?: IUserSettings | null; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 81737c71ca..d70c68691c 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -104,7 +104,7 @@ export class AuthController { authenticationMethod: usedAuthenticationMethod, }); - return this.userService.toPublic(user, { posthog: this.postHog }); + return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } void this.internalHooks.onUserLoginFailed({ user: email, @@ -129,7 +129,7 @@ export class AuthController { try { user = await resolveJwt(cookieContents); - return await this.userService.toPublic(user, { posthog: this.postHog }); + return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } catch (error) { res.clearCookie(AUTH_COOKIE_NAME); } @@ -152,7 +152,7 @@ export class AuthController { } await issueCookie(res, user); - return this.userService.toPublic(user, { posthog: this.postHog }); + return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } /** diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index a32c9f2dc0..dfcc6174a1 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -20,11 +20,18 @@ import { objectRetriever, lowerCaser } from '../utils/transformers'; import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { AuthIdentity } from './AuthIdentity'; +import { ownerPermissions, memberPermissions } from '@/permissions/roles'; +import { hasScope, type HasScopeOptions, type Scope } from '@n8n/permissions'; export const MIN_PASSWORD_LENGTH = 8; export const MAX_PASSWORD_LENGTH = 64; +const STATIC_SCOPE_MAP: Record = { + owner: ownerPermissions, + member: memberPermissions, +}; + @Entity() export class User extends WithTimestamps implements IUser { @PrimaryGeneratedColumn('uuid') @@ -125,4 +132,21 @@ export class User extends WithTimestamps implements IUser { computeIsOwner(): void { this.isOwner = this.globalRole?.name === 'owner'; } + + get globalScopes() { + return STATIC_SCOPE_MAP[this.globalRole?.name] ?? []; + } + + async hasGlobalScope( + scope: Scope | Scope[], + hasScopeOptions?: HasScopeOptions, + ): Promise { + return hasScope( + scope, + { + global: this.globalScopes, + }, + hasScopeOptions, + ); + } } diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/roles.ts new file mode 100644 index 0000000000..95cabb03ca --- /dev/null +++ b/packages/cli/src/permissions/roles.ts @@ -0,0 +1,49 @@ +import type { Scope } from '@n8n/permissions'; + +export const ownerPermissions: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:share', + 'user:create', + 'user:read', + 'user:update', + 'user:delete', + 'user:list', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'variable:create', + 'variable:read', + 'variable:update', + 'variable:delete', + 'variable:list', + 'sourceControl:pull', + 'sourceControl:push', + 'sourceControl:manage', + 'externalSecretsStore:create', + 'externalSecretsStore:read', + 'externalSecretsStore:update', + 'externalSecretsStore:delete', + 'externalSecretsStore:list', + 'externalSecretsStore:refresh', + 'tag:create', + 'tag:read', + 'tag:update', + 'tag:delete', + 'tag:list', +]; +export const adminPermissions: Scope[] = ownerPermissions.concat(); +export const memberPermissions: Scope[] = [ + 'user:list', + 'variable:list', + 'variable:read', + 'tag:create', + 'tag:read', + 'tag:update', + 'tag:list', +]; diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index f7661e89a2..becec7a5fa 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -113,7 +113,10 @@ export class UserService { return user; } - async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) { + async toPublic( + user: User, + options?: { withInviteUrl?: boolean; posthog?: PostHogClient; withScopes?: boolean }, + ) { const { password, updatedAt, apiKey, authIdentities, ...rest } = user; const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); @@ -124,6 +127,10 @@ export class UserService { hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length, }; + if (options?.withScopes) { + publicUser.globalScopes = user.globalScopes; + } + if (options?.withInviteUrl && publicUser.isPending) { publicUser = this.addInviteUrl(publicUser, user.id); } diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 8f54e4ca6b..8b6b2d1957 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -49,8 +49,17 @@ describe('POST /login', () => { expect(response.statusCode).toBe(200); - const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } = - response.body.data; + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + apiKey, + globalScopes, + } = response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(owner.email); @@ -63,6 +72,7 @@ describe('POST /login', () => { expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); expect(apiKey).toBeUndefined(); + expect(globalScopes).toBeDefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeDefined(); @@ -135,8 +145,17 @@ describe('GET /login', () => { expect(response.statusCode).toBe(200); - const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } = - response.body.data; + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + apiKey, + globalScopes, + } = response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBeDefined(); @@ -149,6 +168,8 @@ describe('GET /login', () => { expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); expect(apiKey).toBeUndefined(); + expect(globalScopes).toBeDefined(); + expect(globalScopes).toContain('workflow:read'); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -161,8 +182,17 @@ describe('GET /login', () => { expect(response.statusCode).toBe(200); - const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } = - response.body.data; + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + apiKey, + globalScopes, + } = response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBeDefined(); @@ -175,6 +205,8 @@ describe('GET /login', () => { expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); expect(apiKey).toBeUndefined(); + expect(globalScopes).toBeDefined(); + expect(globalScopes).not.toContain('workflow:read'); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -187,8 +219,17 @@ describe('GET /login', () => { expect(response.statusCode).toBe(200); - const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } = - response.body.data; + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + apiKey, + globalScopes, + } = response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(owner.email); @@ -201,6 +242,8 @@ describe('GET /login', () => { expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); expect(apiKey).toBeUndefined(); + expect(globalScopes).toBeDefined(); + expect(globalScopes).toContain('workflow:read'); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -213,8 +256,17 @@ describe('GET /login', () => { expect(response.statusCode).toBe(200); - const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } = - response.body.data; + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + apiKey, + globalScopes, + } = response.body.data; expect(validator.isUUID(id)).toBe(true); expect(email).toBe(member.email); @@ -227,6 +279,8 @@ describe('GET /login', () => { expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); expect(apiKey).toBeUndefined(); + expect(globalScopes).toBeDefined(); + expect(globalScopes).not.toContain('workflow:read'); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f1b11e804..252908e81d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 + '@n8n/permissions': + specifier: workspace:* + version: link:../@n8n/permissions '@n8n_io/license-sdk': specifier: ~2.7.1 version: 2.7.2