mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: Add scopes to /login endpoint (no-changelog) (#7718)
Github issue / Community forum post (link here to close automatically):
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
|
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
|
||||||
export type Resource =
|
export type Resource =
|
||||||
| 'workflow'
|
| 'workflow'
|
||||||
|
| 'tag'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'credential'
|
| 'credential'
|
||||||
| 'variable'
|
| 'variable'
|
||||||
@@ -13,7 +14,8 @@ export type ResourceScope<
|
|||||||
> = `${R}:${Operations}`;
|
> = `${R}:${Operations}`;
|
||||||
export type WildcardScope = `${Resource}:*` | '*';
|
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 UserScope = ResourceScope<'user'>;
|
||||||
export type CredentialScope = ResourceScope<'credential'>;
|
export type CredentialScope = ResourceScope<'credential'>;
|
||||||
export type VariableScope = ResourceScope<'variable'>;
|
export type VariableScope = ResourceScope<'variable'>;
|
||||||
@@ -25,6 +27,7 @@ export type ExternalSecretStoreScope = ResourceScope<
|
|||||||
|
|
||||||
export type Scope =
|
export type Scope =
|
||||||
| WorkflowScope
|
| WorkflowScope
|
||||||
|
| TagScope
|
||||||
| UserScope
|
| UserScope
|
||||||
| CredentialScope
|
| CredentialScope
|
||||||
| VariableScope
|
| VariableScope
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@n8n/client-oauth2": "workspace:*",
|
"@n8n/client-oauth2": "workspace:*",
|
||||||
|
"@n8n/permissions": "workspace:*",
|
||||||
"@n8n_io/license-sdk": "~2.7.1",
|
"@n8n_io/license-sdk": "~2.7.1",
|
||||||
"@oclif/command": "^1.8.16",
|
"@oclif/command": "^1.8.16",
|
||||||
"@oclif/config": "^1.18.17",
|
"@oclif/config": "^1.18.17",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository';
|
|||||||
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
||||||
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
|
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
|
||||||
import type { WorkerJobStatusSummary } from './services/orchestration/worker/types';
|
import type { WorkerJobStatusSummary } from './services/orchestration/worker/types';
|
||||||
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
|
||||||
export interface ICredentialsTypeData {
|
export interface ICredentialsTypeData {
|
||||||
[key: string]: CredentialLoadingDetails;
|
[key: string]: CredentialLoadingDetails;
|
||||||
@@ -772,6 +773,7 @@ export interface PublicUser {
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
hasRecoveryCodesLeft: boolean;
|
hasRecoveryCodesLeft: boolean;
|
||||||
globalRole?: Role;
|
globalRole?: Role;
|
||||||
|
globalScopes?: Scope[];
|
||||||
signInType: AuthProviderType;
|
signInType: AuthProviderType;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
settings?: IUserSettings | null;
|
settings?: IUserSettings | null;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class AuthController {
|
|||||||
authenticationMethod: usedAuthenticationMethod,
|
authenticationMethod: usedAuthenticationMethod,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.userService.toPublic(user, { posthog: this.postHog });
|
return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
|
||||||
}
|
}
|
||||||
void this.internalHooks.onUserLoginFailed({
|
void this.internalHooks.onUserLoginFailed({
|
||||||
user: email,
|
user: email,
|
||||||
@@ -129,7 +129,7 @@ export class AuthController {
|
|||||||
try {
|
try {
|
||||||
user = await resolveJwt(cookieContents);
|
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) {
|
} catch (error) {
|
||||||
res.clearCookie(AUTH_COOKIE_NAME);
|
res.clearCookie(AUTH_COOKIE_NAME);
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await issueCookie(res, user);
|
await issueCookie(res, user);
|
||||||
return this.userService.toPublic(user, { posthog: this.postHog });
|
return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,11 +20,18 @@ import { objectRetriever, lowerCaser } from '../utils/transformers';
|
|||||||
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
|
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
|
||||||
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
|
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
|
||||||
import type { AuthIdentity } from './AuthIdentity';
|
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 MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
export const MAX_PASSWORD_LENGTH = 64;
|
export const MAX_PASSWORD_LENGTH = 64;
|
||||||
|
|
||||||
|
const STATIC_SCOPE_MAP: Record<string, Scope[]> = {
|
||||||
|
owner: ownerPermissions,
|
||||||
|
member: memberPermissions,
|
||||||
|
};
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User extends WithTimestamps implements IUser {
|
export class User extends WithTimestamps implements IUser {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -125,4 +132,21 @@ export class User extends WithTimestamps implements IUser {
|
|||||||
computeIsOwner(): void {
|
computeIsOwner(): void {
|
||||||
this.isOwner = this.globalRole?.name === 'owner';
|
this.isOwner = this.globalRole?.name === 'owner';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get globalScopes() {
|
||||||
|
return STATIC_SCOPE_MAP[this.globalRole?.name] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasGlobalScope(
|
||||||
|
scope: Scope | Scope[],
|
||||||
|
hasScopeOptions?: HasScopeOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return hasScope(
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
global: this.globalScopes,
|
||||||
|
},
|
||||||
|
hasScopeOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
packages/cli/src/permissions/roles.ts
Normal file
49
packages/cli/src/permissions/roles.ts
Normal file
@@ -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',
|
||||||
|
];
|
||||||
@@ -113,7 +113,10 @@ export class UserService {
|
|||||||
return user;
|
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 { password, updatedAt, apiKey, authIdentities, ...rest } = user;
|
||||||
|
|
||||||
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
||||||
@@ -124,6 +127,10 @@ export class UserService {
|
|||||||
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
|
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options?.withScopes) {
|
||||||
|
publicUser.globalScopes = user.globalScopes;
|
||||||
|
}
|
||||||
|
|
||||||
if (options?.withInviteUrl && publicUser.isPending) {
|
if (options?.withInviteUrl && publicUser.isPending) {
|
||||||
publicUser = this.addInviteUrl(publicUser, user.id);
|
publicUser = this.addInviteUrl(publicUser, user.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,17 @@ describe('POST /login', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
|
const {
|
||||||
response.body.data;
|
id,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
personalizationAnswers,
|
||||||
|
globalRole,
|
||||||
|
apiKey,
|
||||||
|
globalScopes,
|
||||||
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBe(owner.email);
|
expect(email).toBe(owner.email);
|
||||||
@@ -63,6 +72,7 @@ describe('POST /login', () => {
|
|||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(globalScopes).toBeDefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
@@ -135,8 +145,17 @@ describe('GET /login', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
|
const {
|
||||||
response.body.data;
|
id,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
personalizationAnswers,
|
||||||
|
globalRole,
|
||||||
|
apiKey,
|
||||||
|
globalScopes,
|
||||||
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBeDefined();
|
expect(email).toBeDefined();
|
||||||
@@ -149,6 +168,8 @@ describe('GET /login', () => {
|
|||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(globalScopes).toBeDefined();
|
||||||
|
expect(globalScopes).toContain('workflow:read');
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
@@ -161,8 +182,17 @@ describe('GET /login', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
|
const {
|
||||||
response.body.data;
|
id,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
personalizationAnswers,
|
||||||
|
globalRole,
|
||||||
|
apiKey,
|
||||||
|
globalScopes,
|
||||||
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBeDefined();
|
expect(email).toBeDefined();
|
||||||
@@ -175,6 +205,8 @@ describe('GET /login', () => {
|
|||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(globalScopes).toBeDefined();
|
||||||
|
expect(globalScopes).not.toContain('workflow:read');
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
@@ -187,8 +219,17 @@ describe('GET /login', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
|
const {
|
||||||
response.body.data;
|
id,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
personalizationAnswers,
|
||||||
|
globalRole,
|
||||||
|
apiKey,
|
||||||
|
globalScopes,
|
||||||
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBe(owner.email);
|
expect(email).toBe(owner.email);
|
||||||
@@ -201,6 +242,8 @@ describe('GET /login', () => {
|
|||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(globalScopes).toBeDefined();
|
||||||
|
expect(globalScopes).toContain('workflow:read');
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
@@ -213,8 +256,17 @@ describe('GET /login', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
|
const {
|
||||||
response.body.data;
|
id,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
personalizationAnswers,
|
||||||
|
globalRole,
|
||||||
|
apiKey,
|
||||||
|
globalScopes,
|
||||||
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
expect(email).toBe(member.email);
|
expect(email).toBe(member.email);
|
||||||
@@ -227,6 +279,8 @@ describe('GET /login', () => {
|
|||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
expect(apiKey).toBeUndefined();
|
expect(apiKey).toBeUndefined();
|
||||||
|
expect(globalScopes).toBeDefined();
|
||||||
|
expect(globalScopes).not.toContain('workflow:read');
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -196,6 +196,9 @@ importers:
|
|||||||
'@n8n/client-oauth2':
|
'@n8n/client-oauth2':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../@n8n/client-oauth2
|
version: link:../@n8n/client-oauth2
|
||||||
|
'@n8n/permissions':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../@n8n/permissions
|
||||||
'@n8n_io/license-sdk':
|
'@n8n_io/license-sdk':
|
||||||
specifier: ~2.7.1
|
specifier: ~2.7.1
|
||||||
version: 2.7.2
|
version: 2.7.2
|
||||||
|
|||||||
Reference in New Issue
Block a user