mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
feat: Replace owner checks with scope checks (no-changelog) (#7846)
Github issue / Community forum post (link here to close automatically):
This commit is contained in:
parent
d5762a7539
commit
1cb92ffe16
|
@ -30,7 +30,7 @@ export type CommunityPackageScope = ResourceScope<
|
|||
'install' | 'uninstall' | 'update' | 'list' | 'manage'
|
||||
>;
|
||||
export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share'>;
|
||||
export type ExternalSecretScope = ResourceScope<'externalSecret', 'list'>;
|
||||
export type ExternalSecretScope = ResourceScope<'externalSecret', 'list' | 'use'>;
|
||||
export type ExternalSecretProviderScope = ResourceScope<
|
||||
'externalSecretsProvider',
|
||||
DefaultOperations | 'sync'
|
||||
|
@ -46,9 +46,9 @@ export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>
|
|||
export type SamlScope = ResourceScope<'saml', 'manage'>;
|
||||
export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>;
|
||||
export type TagScope = ResourceScope<'tag'>;
|
||||
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword'>;
|
||||
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>;
|
||||
export type VariableScope = ResourceScope<'variable'>;
|
||||
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
|
||||
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share' | 'execute'>;
|
||||
|
||||
export type Scope =
|
||||
| AuditLogsScope
|
||||
|
|
|
@ -68,6 +68,7 @@ import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.reposi
|
|||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee';
|
||||
import { ActivationErrorsService } from '@/ActivationErrors.service';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { NotFoundError } from './errors/response-errors/not-found.error';
|
||||
|
||||
const WEBHOOK_PROD_UNREGISTERED_HINT =
|
||||
|
@ -270,8 +271,8 @@ export class ActiveWorkflowRunner implements IWebhookManager {
|
|||
/**
|
||||
* Get the IDs of active workflows from storage.
|
||||
*/
|
||||
async allActiveInStorage(user?: User) {
|
||||
const isFullAccess = !user || user.globalRole.name === 'owner';
|
||||
async allActiveInStorage(options?: { user: User; scope: Scope | Scope[] }) {
|
||||
const isFullAccess = !options?.user || (await options.user.hasGlobalScope(options.scope));
|
||||
|
||||
const activationErrors = await this.activationErrorsService.getAll();
|
||||
|
||||
|
@ -286,8 +287,9 @@ export class ActiveWorkflowRunner implements IWebhookManager {
|
|||
.filter((workflowId) => !activationErrors[workflowId]);
|
||||
}
|
||||
|
||||
const where = whereClause({
|
||||
user,
|
||||
const where = await whereClause({
|
||||
user: options.user,
|
||||
globalScope: 'workflow:list',
|
||||
entityType: 'workflow',
|
||||
});
|
||||
|
||||
|
|
|
@ -579,7 +579,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||
credentialType,
|
||||
'internal' as WorkflowExecuteMode,
|
||||
undefined,
|
||||
user.isOwner,
|
||||
await user.hasGlobalScope('externalSecret:use'),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.debug('Credential test failed', error);
|
||||
|
|
|
@ -448,7 +448,10 @@ export class Server extends AbstractServer {
|
|||
this.app.get(
|
||||
`/${this.restEndpoint}/active`,
|
||||
ResponseHelper.send(async (req: WorkflowRequest.GetAllActive) => {
|
||||
return this.activeWorkflowRunner.allActiveInStorage(req.user);
|
||||
return this.activeWorkflowRunner.allActiveInStorage({
|
||||
user: req.user,
|
||||
scope: 'workflow:list',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -460,8 +463,9 @@ export class Server extends AbstractServer {
|
|||
|
||||
const shared = await Container.get(SharedWorkflowRepository).findOne({
|
||||
relations: ['workflow'],
|
||||
where: whereClause({
|
||||
where: await whereClause({
|
||||
user: req.user,
|
||||
globalScope: 'workflow:read',
|
||||
entityType: 'workflow',
|
||||
entityId: workflowId,
|
||||
}),
|
||||
|
|
|
@ -32,7 +32,7 @@ export class PermissionChecker {
|
|||
relations: ['globalRole'],
|
||||
});
|
||||
|
||||
if (user.globalRole.name === 'owner') return;
|
||||
if (await user.hasGlobalScope('workflow:execute')) return;
|
||||
|
||||
// allow if all creds used in this workflow are a subset of
|
||||
// all creds accessible to users who have access to this workflow
|
||||
|
|
|
@ -10,6 +10,7 @@ import { License } from '@/License';
|
|||
import { getWebhookBaseUrl } from '@/WebhookHelpers';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { UserRepository } from '@db/repositories/user.repository';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
|
@ -131,21 +132,22 @@ export function rightDiff<T1, T2>(
|
|||
* Build a `where` clause for a TypeORM entity search,
|
||||
* checking for member access if the user is not an owner.
|
||||
*/
|
||||
export function whereClause({
|
||||
export async function whereClause({
|
||||
user,
|
||||
entityType,
|
||||
globalScope,
|
||||
entityId = '',
|
||||
roles = [],
|
||||
}: {
|
||||
user: User;
|
||||
entityType: 'workflow' | 'credentials';
|
||||
globalScope: Scope;
|
||||
entityId?: string;
|
||||
roles?: string[];
|
||||
}): WhereClause {
|
||||
}): Promise<WhereClause> {
|
||||
const where: WhereClause = entityId ? { [entityType]: { id: entityId } } : {};
|
||||
|
||||
// TODO: Decide if owner access should be restricted
|
||||
if (user.globalRole.name !== 'owner') {
|
||||
if (!(await user.hasGlobalScope(globalScope))) {
|
||||
where.user = { id: user.id };
|
||||
if (roles?.length) {
|
||||
where.role = { name: In(roles) };
|
||||
|
|
|
@ -415,11 +415,11 @@ export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promi
|
|||
|
||||
/**
|
||||
* Get the IDs of the workflows that have been shared with the user.
|
||||
* Returns all IDs if user is global owner (see `whereClause`)
|
||||
* Returns all IDs if user has the 'workflow:read' scope (see `whereClause`)
|
||||
*/
|
||||
export async function getSharedWorkflowIds(user: User, roles?: RoleNames[]): Promise<string[]> {
|
||||
const where: FindOptionsWhere<SharedWorkflow> = {};
|
||||
if (user.globalRole?.name !== 'owner') {
|
||||
if (!(await user.hasGlobalScope('workflow:read'))) {
|
||||
where.userId = user.id;
|
||||
}
|
||||
if (roles?.length) {
|
||||
|
|
|
@ -97,7 +97,10 @@ export class PasswordResetController {
|
|||
}
|
||||
if (
|
||||
isSamlCurrentAuthenticationMethod() &&
|
||||
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
|
||||
!(
|
||||
(user && (await user.hasGlobalScope('user:resetPassword'))) === true ||
|
||||
user?.settings?.allowSSOManualLogin === true
|
||||
)
|
||||
) {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because login is handled by SAML',
|
||||
|
|
|
@ -38,7 +38,6 @@ export class UsersController {
|
|||
|
||||
static ERROR_MESSAGES = {
|
||||
CHANGE_ROLE: {
|
||||
NO_MEMBER: 'Member cannot change role for any user',
|
||||
MISSING_NEW_ROLE_KEY: 'Expected `newRole` to exist',
|
||||
MISSING_NEW_ROLE_VALUE: 'Expected `newRole` to have `name` and `scope`',
|
||||
NO_USER: 'Target user not found',
|
||||
|
@ -326,13 +325,10 @@ export class UsersController {
|
|||
return { success: true };
|
||||
}
|
||||
|
||||
// @TODO: Add scope check `@RequireGlobalScope('user:changeRole')`
|
||||
// once this has been merged: https://github.com/n8n-io/n8n/pull/7737
|
||||
@Authorized('any')
|
||||
@Patch('/:id/role')
|
||||
@RequireGlobalScope('user:changeRole')
|
||||
async changeRole(req: UserRequest.ChangeRole) {
|
||||
const {
|
||||
NO_MEMBER,
|
||||
MISSING_NEW_ROLE_KEY,
|
||||
MISSING_NEW_ROLE_VALUE,
|
||||
NO_ADMIN_ON_OWNER,
|
||||
|
@ -342,10 +338,6 @@ export class UsersController {
|
|||
NO_ADMIN_IF_UNLICENSED,
|
||||
} = UsersController.ERROR_MESSAGES.CHANGE_ROLE;
|
||||
|
||||
if (req.user.globalRole.scope === 'global' && req.user.globalRole.name === 'member') {
|
||||
throw new UnauthorizedError(NO_MEMBER);
|
||||
}
|
||||
|
||||
const { newRole } = req.body;
|
||||
|
||||
if (!newRole) {
|
||||
|
|
|
@ -37,8 +37,9 @@ export class WorkflowStatisticsController {
|
|||
const workflowId = req.params.id;
|
||||
const allowed = await this.sharedWorkflowRepository.exist({
|
||||
relations: ['workflow'],
|
||||
where: whereClause({
|
||||
where: await whereClause({
|
||||
user,
|
||||
globalScope: 'workflow:read',
|
||||
entityType: 'workflow',
|
||||
entityId: workflowId,
|
||||
}),
|
||||
|
|
|
@ -50,7 +50,7 @@ EECredentialsController.get(
|
|||
|
||||
const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);
|
||||
|
||||
if (!userSharing && req.user.globalRole.name !== 'owner') {
|
||||
if (!userSharing && !(await req.user.hasGlobalScope('credential:read'))) {
|
||||
throw new UnauthorizedError('Forbidden.');
|
||||
}
|
||||
|
||||
|
@ -82,7 +82,10 @@ EECredentialsController.post(
|
|||
const credentialId = credentials.id;
|
||||
const { ownsCredential } = await EECredentials.isOwned(req.user, credentialId);
|
||||
|
||||
const sharing = await EECredentials.getSharing(req.user, credentialId);
|
||||
const sharing = await EECredentials.getSharing(req.user, credentialId, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:read',
|
||||
});
|
||||
if (!ownsCredential) {
|
||||
if (!sharing) {
|
||||
throw new UnauthorizedError('Forbidden');
|
||||
|
|
|
@ -58,7 +58,12 @@ credentialsController.get(
|
|||
const { id: credentialId } = req.params;
|
||||
const includeDecryptedData = req.query.includeData === 'true';
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId, ['credentials']);
|
||||
const sharing = await CredentialsService.getSharing(
|
||||
req.user,
|
||||
credentialId,
|
||||
{ allowGlobalScope: true, globalScope: 'credential:read' },
|
||||
['credentials'],
|
||||
);
|
||||
|
||||
if (!sharing) {
|
||||
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
|
||||
|
@ -91,7 +96,10 @@ credentialsController.post(
|
|||
ResponseHelper.send(async (req: CredentialRequest.Test): Promise<INodeCredentialTestResult> => {
|
||||
const { credentials } = req.body;
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentials.id);
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentials.id, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:read',
|
||||
});
|
||||
|
||||
const mergedCredentials = deepCopy(credentials);
|
||||
if (mergedCredentials.data && sharing?.credentials) {
|
||||
|
@ -134,7 +142,10 @@ credentialsController.patch(
|
|||
ResponseHelper.send(async (req: CredentialRequest.Update): Promise<ICredentialsDb> => {
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId);
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:update',
|
||||
});
|
||||
|
||||
if (!sharing) {
|
||||
Container.get(Logger).info(
|
||||
|
@ -184,7 +195,10 @@ credentialsController.delete(
|
|||
ResponseHelper.send(async (req: CredentialRequest.Delete) => {
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId);
|
||||
const sharing = await CredentialsService.getSharing(req.user, credentialId, {
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:delete',
|
||||
});
|
||||
|
||||
if (!sharing) {
|
||||
Container.get(Logger).info(
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
|||
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { CredentialsService } from './credentials.service';
|
||||
import { CredentialsService, type CredentialsGetSharedOptions } from './credentials.service';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import Container from 'typedi';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
|
@ -14,9 +14,10 @@ export class EECredentialsService extends CredentialsService {
|
|||
user: User,
|
||||
credentialId: string,
|
||||
): Promise<{ ownsCredential: boolean; credential?: CredentialsEntity }> {
|
||||
const sharing = await this.getSharing(user, credentialId, ['credentials', 'role'], {
|
||||
allowGlobalOwner: false,
|
||||
});
|
||||
const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [
|
||||
'credentials',
|
||||
'role',
|
||||
]);
|
||||
|
||||
if (!sharing || sharing.role.name !== 'owner') return { ownsCredential: false };
|
||||
|
||||
|
@ -31,15 +32,15 @@ export class EECredentialsService extends CredentialsService {
|
|||
static async getSharing(
|
||||
user: User,
|
||||
credentialId: string,
|
||||
options: CredentialsGetSharedOptions,
|
||||
relations: string[] = ['credentials'],
|
||||
{ allowGlobalOwner } = { allowGlobalOwner: true },
|
||||
): Promise<SharedCredentials | null> {
|
||||
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
|
||||
// Omit user from where if the requesting user is the global
|
||||
// owner. This allows the global owner to view and delete
|
||||
// credentials they don't own.
|
||||
if (!allowGlobalOwner || user.globalRole.name !== 'owner') {
|
||||
// Omit user from where if the requesting user has relevant
|
||||
// global credential permissions. This allows the user to
|
||||
// access credentials they don't own.
|
||||
if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) {
|
||||
where.userId = user.id;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ import { Container } from 'typedi';
|
|||
import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
|
||||
import { In, Like } from 'typeorm';
|
||||
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
import * as Db from '@/Db';
|
||||
import type { ICredentialsDb } from '@/Interfaces';
|
||||
import { CredentialsHelper, createCredentialsFromCredentialsEntity } from '@/CredentialsHelper';
|
||||
|
@ -28,6 +30,10 @@ import { Logger } from '@/Logger';
|
|||
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
|
||||
export type CredentialsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
| { allowGlobalScope: false };
|
||||
|
||||
export class CredentialsService {
|
||||
static async get(
|
||||
where: FindOptionsWhere<ICredentialsDb>,
|
||||
|
@ -86,7 +92,7 @@ export class CredentialsService {
|
|||
) {
|
||||
const findManyOptions = this.toFindManyOptions(options.listQueryOptions);
|
||||
|
||||
const returnAll = user.globalRole.name === 'owner' && !options.onlyOwn;
|
||||
const returnAll = (await user.hasGlobalScope('credential:list')) && !options.onlyOwn;
|
||||
const isDefaultSelect = !options.listQueryOptions?.select;
|
||||
|
||||
if (returnAll) {
|
||||
|
@ -136,15 +142,15 @@ export class CredentialsService {
|
|||
static async getSharing(
|
||||
user: User,
|
||||
credentialId: string,
|
||||
options: CredentialsGetSharedOptions,
|
||||
relations: string[] = ['credentials'],
|
||||
{ allowGlobalOwner } = { allowGlobalOwner: true },
|
||||
): Promise<SharedCredentials | null> {
|
||||
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
|
||||
// Omit user from where if the requesting user is the global
|
||||
// owner. This allows the global owner to view and delete
|
||||
// credentials they don't own.
|
||||
if (!allowGlobalOwner || user.globalRole.name !== 'owner') {
|
||||
// Omit user from where if the requesting user has relevant
|
||||
// global credential permissions. This allows the user to
|
||||
// access credentials they don't own.
|
||||
if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) {
|
||||
Object.assign(where, {
|
||||
userId: user.id,
|
||||
role: { name: 'owner' },
|
||||
|
|
|
@ -273,10 +273,10 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
filters: IGetExecutionsQueryFilter | undefined,
|
||||
accessibleWorkflowIds: string[],
|
||||
currentlyRunningExecutions: string[],
|
||||
isOwner: boolean,
|
||||
hasGlobalRead: boolean,
|
||||
): Promise<{ count: number; estimated: boolean }> {
|
||||
const dbType = config.getEnv('database.type');
|
||||
if (dbType !== 'postgresdb' || (filters && Object.keys(filters).length > 0) || !isOwner) {
|
||||
if (dbType !== 'postgresdb' || (filters && Object.keys(filters).length > 0) || !hasGlobalRead) {
|
||||
const query = this.createQueryBuilder('execution').andWhere(
|
||||
'execution.workflowId IN (:...accessibleWorkflowIds)',
|
||||
{ accessibleWorkflowIds },
|
||||
|
|
|
@ -15,7 +15,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
|||
relations: ['credentials'],
|
||||
where: {
|
||||
credentialsId,
|
||||
...(!user.isOwner ? { userId: user.id } : {}),
|
||||
...(!(await user.hasGlobalScope('credential:read')) ? { userId: user.id } : {}),
|
||||
},
|
||||
});
|
||||
if (!sharedCredential) return null;
|
||||
|
|
|
@ -155,7 +155,7 @@ export class ExecutionsService {
|
|||
filter,
|
||||
sharedWorkflowIds,
|
||||
executingWorkflowIds,
|
||||
req.user.globalRole.name === 'owner',
|
||||
await req.user.hasGlobalScope('workflow:list'),
|
||||
);
|
||||
|
||||
const formattedExecutions = await Container.get(ExecutionRepository).searchExecutions(
|
||||
|
|
|
@ -32,6 +32,7 @@ export const ownerPermissions: Scope[] = [
|
|||
'externalSecretsProvider:list',
|
||||
'externalSecretsProvider:sync',
|
||||
'externalSecret:list',
|
||||
'externalSecret:use',
|
||||
'ldap:manage',
|
||||
'ldap:sync',
|
||||
'logStreaming:manage',
|
||||
|
@ -52,6 +53,7 @@ export const ownerPermissions: Scope[] = [
|
|||
'user:delete',
|
||||
'user:list',
|
||||
'user:resetPassword',
|
||||
'user:changeRole',
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
|
@ -63,6 +65,7 @@ export const ownerPermissions: Scope[] = [
|
|||
'workflow:delete',
|
||||
'workflow:list',
|
||||
'workflow:share',
|
||||
'workflow:execute',
|
||||
];
|
||||
export const adminPermissions: Scope[] = ownerPermissions.concat();
|
||||
export const memberPermissions: Scope[] = [
|
||||
|
|
|
@ -21,7 +21,7 @@ export class WorkflowHistoryService {
|
|||
private async getSharedWorkflow(user: User, workflowId: string): Promise<SharedWorkflow | null> {
|
||||
return this.sharedWorkflowRepository.findOne({
|
||||
where: {
|
||||
...(!user.isOwner && { userId: user.id }),
|
||||
...(!(await user.hasGlobalScope('workflow:read')) && { userId: user.id }),
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -211,9 +211,10 @@ workflowsController.get(
|
|||
|
||||
const shared = await Container.get(SharedWorkflowRepository).findOne({
|
||||
relations,
|
||||
where: whereClause({
|
||||
where: await whereClause({
|
||||
user: req.user,
|
||||
entityType: 'workflow',
|
||||
globalScope: 'workflow:read',
|
||||
entityId: workflowId,
|
||||
roles: ['owner'],
|
||||
}),
|
||||
|
|
|
@ -24,9 +24,10 @@ export class EEWorkflowsService extends WorkflowsService {
|
|||
user: User,
|
||||
workflowId: string,
|
||||
): Promise<{ ownsWorkflow: boolean; workflow?: WorkflowEntity }> {
|
||||
const sharing = await this.getSharing(user, workflowId, ['workflow', 'role'], {
|
||||
allowGlobalOwner: false,
|
||||
});
|
||||
const sharing = await this.getSharing(user, workflowId, { allowGlobalScope: false }, [
|
||||
'workflow',
|
||||
'role',
|
||||
]);
|
||||
|
||||
if (!sharing || sharing.role.name !== 'owner') return { ownsWorkflow: false };
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import { OwnershipService } from '@/services/ownership.service';
|
|||
import { isStringArray, isWorkflowIdValid } from '@/utils';
|
||||
import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee';
|
||||
import { BinaryDataService } from 'n8n-core';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { Logger } from '@/Logger';
|
||||
import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee';
|
||||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
||||
|
@ -36,19 +37,23 @@ import { ExecutionRepository } from '@db/repositories/execution.repository';
|
|||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
export type WorkflowsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
| { allowGlobalScope: false };
|
||||
|
||||
export class WorkflowsService {
|
||||
static async getSharing(
|
||||
user: User,
|
||||
workflowId: string,
|
||||
options: WorkflowsGetSharedOptions,
|
||||
relations: string[] = ['workflow'],
|
||||
{ allowGlobalOwner } = { allowGlobalOwner: true },
|
||||
): Promise<SharedWorkflow | null> {
|
||||
const where: FindOptionsWhere<SharedWorkflow> = { workflowId };
|
||||
|
||||
// Omit user from where if the requesting user is the global
|
||||
// owner. This allows the global owner to view and delete
|
||||
// workflows they don't own.
|
||||
if (!allowGlobalOwner || user.globalRole.name !== 'owner') {
|
||||
// Omit user from where if the requesting user has relevant
|
||||
// global workflow permissions. This allows the user to
|
||||
// access workflows they don't own.
|
||||
if (!options.allowGlobalScope || !(await user.hasGlobalScope(options.globalScope))) {
|
||||
where.userId = user.id;
|
||||
}
|
||||
|
||||
|
@ -195,8 +200,9 @@ export class WorkflowsService {
|
|||
): Promise<WorkflowEntity> {
|
||||
const shared = await Container.get(SharedWorkflowRepository).findOne({
|
||||
relations: ['workflow', 'role'],
|
||||
where: whereClause({
|
||||
where: await whereClause({
|
||||
user,
|
||||
globalScope: 'workflow:update',
|
||||
entityType: 'workflow',
|
||||
entityId: workflowId,
|
||||
roles,
|
||||
|
@ -476,8 +482,9 @@ export class WorkflowsService {
|
|||
|
||||
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
|
||||
relations: ['workflow', 'role'],
|
||||
where: whereClause({
|
||||
where: await whereClause({
|
||||
user,
|
||||
globalScope: 'workflow:delete',
|
||||
entityType: 'workflow',
|
||||
entityId: workflowId,
|
||||
roles: ['owner'],
|
||||
|
|
|
@ -97,7 +97,6 @@ describe('POST /login', () => {
|
|||
const ownerUser = await createUser({
|
||||
password: randomValidPassword(),
|
||||
globalRole: globalOwnerRole,
|
||||
isOwner: true,
|
||||
});
|
||||
|
||||
const response = await testServer.authAgentFor(ownerUser).get('/login');
|
||||
|
|
|
@ -16,7 +16,7 @@ import { getGlobalAdminRole, getGlobalMemberRole, getGlobalOwnerRole } from './r
|
|||
*/
|
||||
export async function createUser(attributes: Partial<User> = {}): Promise<User> {
|
||||
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
|
||||
const user: Partial<User> = {
|
||||
const user = Container.get(UserRepository).create({
|
||||
email: email ?? randomEmail(),
|
||||
password: await hash(password ?? randomValidPassword(), 10),
|
||||
firstName: firstName ?? randomName(),
|
||||
|
@ -24,7 +24,8 @@ export async function createUser(attributes: Partial<User> = {}): Promise<User>
|
|||
globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id,
|
||||
globalRole,
|
||||
...rest,
|
||||
};
|
||||
});
|
||||
user.computeIsOwner();
|
||||
|
||||
return Container.get(UserRepository).save(user);
|
||||
}
|
||||
|
|
|
@ -362,7 +362,6 @@ describe('PATCH /users/:id/role', () => {
|
|||
let authlessAgent: SuperAgentTest;
|
||||
|
||||
const {
|
||||
NO_MEMBER,
|
||||
MISSING_NEW_ROLE_KEY,
|
||||
MISSING_NEW_ROLE_VALUE,
|
||||
NO_ADMIN_ON_OWNER,
|
||||
|
@ -372,6 +371,8 @@ describe('PATCH /users/:id/role', () => {
|
|||
NO_ADMIN_IF_UNLICENSED,
|
||||
} = UsersController.ERROR_MESSAGES.CHANGE_ROLE;
|
||||
|
||||
const UNAUTHORIZED = 'Unauthorized';
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
|
||||
|
@ -406,7 +407,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to demote owner to admin', async () => {
|
||||
|
@ -415,7 +416,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to demote admin to member', async () => {
|
||||
|
@ -424,7 +425,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to promote other member to owner', async () => {
|
||||
|
@ -433,7 +434,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to promote other member to admin', async () => {
|
||||
|
@ -442,7 +443,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to promote self to admin', async () => {
|
||||
|
@ -451,7 +452,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('should fail to promote self to owner', async () => {
|
||||
|
@ -460,7 +461,7 @@ describe('PATCH /users/:id/role', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(NO_MEMBER);
|
||||
expect(response.body.message).toBe(UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
|||
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
import { mockInstance } from '../../shared/mocking';
|
||||
import { memberPermissions, ownerPermissions } from '@/permissions/roles';
|
||||
import { hasScope } from '@n8n/permissions';
|
||||
|
||||
describe('SharedCredentialsRepository', () => {
|
||||
const entityManager = mockInstance(EntityManager);
|
||||
|
@ -20,8 +22,23 @@ describe('SharedCredentialsRepository', () => {
|
|||
const credentialsId = 'cred_123';
|
||||
const sharedCredential = mock<SharedCredentials>();
|
||||
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
|
||||
const owner = mock<User>({ isOwner: true });
|
||||
const member = mock<User>({ isOwner: false, id: 'test' });
|
||||
const owner = mock<User>({
|
||||
isOwner: true,
|
||||
hasGlobalScope: async (scope) => {
|
||||
return hasScope(scope, {
|
||||
global: ownerPermissions,
|
||||
});
|
||||
},
|
||||
});
|
||||
const member = mock<User>({
|
||||
isOwner: false,
|
||||
id: 'test',
|
||||
hasGlobalScope: async (scope) => {
|
||||
return hasScope(scope, {
|
||||
global: memberPermissions,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
|
Loading…
Reference in a new issue