mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08: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:
parent
ebee1a5908
commit
d39bb2540f
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<string, Scope[]> = {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue