mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-25 11:31:38 -08:00
feat(core): Support create, delete, edit role for users in Public API (#10279)
This commit is contained in:
parent
a533916628
commit
84efbd9b9c
|
@ -0,0 +1,31 @@
|
||||||
|
patch:
|
||||||
|
x-eov-operation-id: changeRole
|
||||||
|
x-eov-operation-handler: v1/handlers/users/users.handler.ee
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: Change a user's global role
|
||||||
|
description: Change a user's global role
|
||||||
|
parameters:
|
||||||
|
- $ref: '../schemas/parameters/userIdentifier.yml'
|
||||||
|
requestBody:
|
||||||
|
description: New role for the user
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
newRoleName:
|
||||||
|
type: string
|
||||||
|
enum: [global:admin, global:member]
|
||||||
|
required:
|
||||||
|
- newRoleName
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Operation successful.
|
||||||
|
'401':
|
||||||
|
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||||
|
'403':
|
||||||
|
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||||
|
'404':
|
||||||
|
$ref: '../../../../shared/spec/responses/notFound.yml'
|
|
@ -17,3 +17,21 @@ get:
|
||||||
$ref: '../schemas/user.yml'
|
$ref: '../schemas/user.yml'
|
||||||
'401':
|
'401':
|
||||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||||
|
delete:
|
||||||
|
x-eov-operation-id: deleteUser
|
||||||
|
x-eov-operation-handler: v1/handlers/users/users.handler.ee
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: Delete a user
|
||||||
|
description: Delete a user from your instance.
|
||||||
|
parameters:
|
||||||
|
- $ref: '../schemas/parameters/userIdentifier.yml'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Operation successful.
|
||||||
|
'401':
|
||||||
|
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||||
|
'403':
|
||||||
|
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||||
|
'404':
|
||||||
|
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||||
|
|
|
@ -18,3 +18,53 @@ get:
|
||||||
$ref: '../schemas/userList.yml'
|
$ref: '../schemas/userList.yml'
|
||||||
'401':
|
'401':
|
||||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||||
|
post:
|
||||||
|
x-eov-operation-id: createUser
|
||||||
|
x-eov-operation-handler: v1/handlers/users/users.handler.ee
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
|
summary: Create multiple users
|
||||||
|
description: Create one or more users.
|
||||||
|
requestBody:
|
||||||
|
description: Array of users to be created.
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [global:admin, global:member]
|
||||||
|
required:
|
||||||
|
- email
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Operation successful.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
inviteAcceptUrl:
|
||||||
|
type: string
|
||||||
|
emailSent:
|
||||||
|
type: boolean
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||||
|
'403':
|
||||||
|
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||||
|
|
|
@ -6,11 +6,19 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee';
|
||||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||||
import {
|
import {
|
||||||
globalScope,
|
globalScope,
|
||||||
|
isLicensed,
|
||||||
validCursor,
|
validCursor,
|
||||||
validLicenseWithUserQuota,
|
validLicenseWithUserQuota,
|
||||||
} from '../../shared/middlewares/global.middleware';
|
} from '../../shared/middlewares/global.middleware';
|
||||||
import type { UserRequest } from '@/requests';
|
import type { UserRequest } from '@/requests';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { InvitationController } from '@/controllers/invitation.controller';
|
||||||
|
import { UsersController } from '@/controllers/users.controller';
|
||||||
|
|
||||||
|
type Create = UserRequest.Invite;
|
||||||
|
type Delete = UserRequest.Delete;
|
||||||
|
type ChangeRole = UserRequest.ChangeRole;
|
||||||
|
|
||||||
export = {
|
export = {
|
||||||
getUser: [
|
getUser: [
|
||||||
|
@ -68,4 +76,29 @@ export = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
createUser: [
|
||||||
|
globalScope('user:create'),
|
||||||
|
async (req: Create, res: Response) => {
|
||||||
|
const usersInvited = await Container.get(InvitationController).inviteUser(req);
|
||||||
|
|
||||||
|
return res.status(201).json(usersInvited);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deleteUser: [
|
||||||
|
globalScope('user:delete'),
|
||||||
|
async (req: Delete, res: Response) => {
|
||||||
|
await Container.get(UsersController).deleteUser(req);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
changeRole: [
|
||||||
|
isLicensed('feat:advancedPermissions'),
|
||||||
|
globalScope('user:changeRole'),
|
||||||
|
async (req: ChangeRole, res: Response) => {
|
||||||
|
await Container.get(UsersController).changeGlobalRole(req);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -70,6 +70,8 @@ paths:
|
||||||
$ref: './handlers/users/spec/paths/users.yml'
|
$ref: './handlers/users/spec/paths/users.yml'
|
||||||
/users/{id}:
|
/users/{id}:
|
||||||
$ref: './handlers/users/spec/paths/users.id.yml'
|
$ref: './handlers/users/spec/paths/users.id.yml'
|
||||||
|
/users/{id}/role:
|
||||||
|
$ref: './handlers/users/spec/paths/users.id.role.yml'
|
||||||
/source-control/pull:
|
/source-control/pull:
|
||||||
$ref: './handlers/sourceControl/spec/paths/sourceControl.yml'
|
$ref: './handlers/sourceControl/spec/paths/sourceControl.yml'
|
||||||
/variables:
|
/variables:
|
||||||
|
|
252
packages/cli/test/integration/publicApi/users.test.ts
Normal file
252
packages/cli/test/integration/publicApi/users.test.ts
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
import { setupTestServer } from '@test-integration/utils';
|
||||||
|
import * as testDb from '../shared/testDb';
|
||||||
|
import { createMember, createOwner, getUserById } from '@test-integration/db/users';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
import { Telemetry } from '@/telemetry';
|
||||||
|
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||||
|
|
||||||
|
describe('Users in Public API', () => {
|
||||||
|
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
|
||||||
|
mockInstance(Telemetry);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testDb.truncate(['User']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /users', () => {
|
||||||
|
it('if not authenticated, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const owner = await createOwner({ withApiKey: false });
|
||||||
|
const payload = { email: 'test@test.com', role: 'global:admin' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if missing scope, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const member = await createMember({ withApiKey: true });
|
||||||
|
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(member).post('/users').send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a user', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const owner = await createOwner({ withApiKey: true });
|
||||||
|
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
|
||||||
|
expect(response.body).toHaveLength(1);
|
||||||
|
|
||||||
|
const [result] = response.body;
|
||||||
|
const { user: returnedUser, error } = result;
|
||||||
|
const payloadUser = payload[0];
|
||||||
|
|
||||||
|
expect(returnedUser).toHaveProperty('email', payload[0].email);
|
||||||
|
expect(typeof returnedUser.inviteAcceptUrl).toBe('string');
|
||||||
|
expect(typeof returnedUser.emailSent).toBe('boolean');
|
||||||
|
expect(error).toBe('');
|
||||||
|
|
||||||
|
const storedUser = await getUserById(returnedUser.id);
|
||||||
|
expect(returnedUser.id).toBe(storedUser.id);
|
||||||
|
expect(returnedUser.email).toBe(storedUser.email);
|
||||||
|
expect(returnedUser.email).toBe(payloadUser.email);
|
||||||
|
expect(storedUser.role).toBe(payloadUser.role);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /users/:id', () => {
|
||||||
|
it('if not authenticated, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const owner = await createOwner({ withApiKey: false });
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if missing scope, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const firstMember = await createMember({ withApiKey: true });
|
||||||
|
const secondMember = await createMember();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(firstMember)
|
||||||
|
.delete(`/users/${secondMember.id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a user', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const owner = await createOwner({ withApiKey: true });
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
await expect(getUserById(member.id)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /users/:id/role', () => {
|
||||||
|
it('if not authenticated, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const owner = await createOwner({ withApiKey: false });
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if not licensed, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const owner = await createOwner({ withApiKey: true });
|
||||||
|
const member = await createMember();
|
||||||
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.patch(`/users/${member.id}/role`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
new FeatureNotLicensedError('feat:advancedPermissions').message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if missing scope, should reject', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const firstMember = await createMember({ withApiKey: true });
|
||||||
|
const secondMember = await createMember();
|
||||||
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(firstMember)
|
||||||
|
.patch(`/users/${secondMember.id}/role`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should change a user's role", async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
testServer.license.enable('feat:advancedPermissions');
|
||||||
|
const owner = await createOwner({ withApiKey: true });
|
||||||
|
const member = await createMember();
|
||||||
|
const payload = { newRoleName: 'global:admin' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.patch(`/users/${member.id}/role`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
const storedUser = await getUserById(member.id);
|
||||||
|
expect(storedUser.role).toBe(payload.newRoleName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue