feat: Add scopes to /login endpoint (no-changelog) (#7718)

Github issue / Community forum post (link here to close automatically):
This commit is contained in:
Val 2023-11-16 11:11:55 +00:00 committed by GitHub
parent ebee1a5908
commit d39bb2540f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 158 additions and 15 deletions

View file

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

View file

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

View file

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

View file

@ -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 });
}
/**

View file

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

View 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',
];

View file

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

View file

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

View file

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