Backend support

This commit is contained in:
Jori Lallo
2018-03-04 15:38:51 -08:00
parent 7272b24eaf
commit 3d6b9466fb
11 changed files with 414 additions and 128 deletions

View File

@ -1,5 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#user.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"name": "User 1",
"username": "user1",
},
"ok": true,
"status": 200,
}
`;
exports[`#user.activate should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#user.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"name": "User 1",
"username": "user1",
},
"ok": true,
"status": 200,
}
`;
exports[`#user.demote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#user.demote shouldn't demote admins if only one available 1`] = `
Object {
"error": "validation_error",
"message": "At least one admin is required",
"ok": false,
"status": 400,
}
`;
exports[`#user.info should require authentication 1`] = ` exports[`#user.info should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",
@ -22,6 +81,65 @@ Object {
} }
`; `;
exports[`#user.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
"isSuspended": false,
"name": "User 1",
"username": "user1",
},
"ok": true,
"status": 200,
}
`;
exports[`#user.promote should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#user.suspend should require admin 1`] = `
Object {
"error": "admin_required",
"message": "An admin role is required to access this resource",
"ok": false,
"status": 403,
}
`;
exports[`#user.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": true,
"name": "User 1",
"username": "user1",
},
"ok": true,
"status": 200,
}
`;
exports[`#user.suspend shouldn't allow suspending the user themselves 1`] = `
Object {
"error": "validation_error",
"message": "Unable to suspend the current user",
"ok": false,
"status": 400,
}
`;
exports[`#user.update should require authentication 1`] = ` exports[`#user.update should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",

View File

@ -1,14 +1,11 @@
// @flow // @flow
import Router from 'koa-router'; import Router from 'koa-router';
import { ValidationError } from '../errors'; import { User } from '../models';
import { Team, User } from '../models';
import auth from './middlewares/authentication'; import auth from './middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import { presentUser } from '../presenters'; import { presentUser } from '../presenters';
import policy from '../policies';
const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post('team.users', auth(), pagination(), async ctx => { router.post('team.users', auth(), pagination(), async ctx => {
@ -31,41 +28,4 @@ router.post('team.users', auth(), pagination(), async ctx => {
}; };
}); });
router.post('team.addAdmin', auth(), async ctx => {
const userId = ctx.body.user;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'promote', user);
const team = await Team.findById(teamId);
await team.addAdmin(user);
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
router.post('team.removeAdmin', auth(), async ctx => {
const userId = ctx.body.user;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'demote', user);
const team = await Team.findById(teamId);
try {
await team.removeAdmin(user);
} catch (err) {
throw new ValidationError(err.message);
}
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
export default router; export default router;

View File

@ -34,72 +34,3 @@ describe('#team.users', async () => {
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
}); });
describe('#team.addAdmin', async () => {
it('should promote a new admin', async () => {
const { admin, user } = await seed();
const res = await server.post('/api/team.addAdmin', {
body: { token: admin.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/team.addAdmin', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe('#team.removeAdmin', async () => {
it('should demote an admin', async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post('/api/team.removeAdmin', {
body: {
token: admin.getJwtToken(),
user: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("shouldn't demote admins if only one available ", async () => {
const { admin } = await seed();
const res = await server.post('/api/team.removeAdmin', {
body: {
token: admin.getJwtToken(),
user: admin.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/team.addAdmin', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});

View File

@ -2,10 +2,13 @@
import uuid from 'uuid'; import uuid from 'uuid';
import Router from 'koa-router'; import Router from 'koa-router';
import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3'; import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3';
import { Event } from '../models'; import { ValidationError } from '../errors';
import { Event, User, Team } from '../models';
import auth from './middlewares/authentication'; import auth from './middlewares/authentication';
import { presentUser } from '../presenters'; import { presentUser } from '../presenters';
import policy from '../policies';
const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post('user.info', auth(), async ctx => { router.post('user.info', auth(), async ctx => {
@ -75,4 +78,91 @@ router.post('user.s3Upload', auth(), async ctx => {
}; };
}); });
// Admin specific
router.post('user.promote', auth(), async ctx => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'promote', user);
const team = await Team.findById(teamId);
await team.addAdmin(user);
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
router.post('user.demote', auth(), async ctx => {
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'id is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'demote', user);
const team = await Team.findById(teamId);
try {
await team.removeAdmin(user);
} catch (err) {
throw new ValidationError(err.message);
}
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
/**
* Suspend user
*
* Admin can suspend users to reduce the number of accounts on their billing plan
*/
router.post('user.suspend', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'user is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'suspend', user);
const team = await Team.findById(teamId);
try {
await team.suspendUser(user, admin);
} catch (err) {
throw new ValidationError(err.message);
}
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
/**
* Activate user
*
* Admin can activate users to let them access resources. These users will also
* account towards the billing plan limits.
*/
router.post('user.activate', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
const teamId = ctx.state.user.teamId;
ctx.assertPresent(userId, 'user is required');
const user = await User.findById(userId);
authorize(ctx.state.user, 'activate', user);
const team = await Team.findById(teamId);
await team.activateUser(user, admin);
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
};
});
export default router; export default router;

View File

@ -66,3 +66,148 @@ describe('#user.update', async () => {
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
}); });
describe('#user.promote', async () => {
it('should promote a new admin', async () => {
const { admin, user } = await seed();
const res = await server.post('/api/user.promote', {
body: { token: admin.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe('#user.demote', async () => {
it('should demote an admin', async () => {
const { admin, user } = await seed();
await user.update({ isAdmin: true }); // Make another admin
const res = await server.post('/api/user.demote', {
body: {
token: admin.getJwtToken(),
user: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("shouldn't demote admins if only one available ", async () => {
const { admin } = await seed();
const res = await server.post('/api/user.demote', {
body: {
token: admin.getJwtToken(),
user: admin.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/user.promote', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe('#user.suspend', async () => {
it('should suspend an user', async () => {
const { admin, user } = await seed();
const res = await server.post('/api/user.suspend', {
body: {
token: admin.getJwtToken(),
user: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it("shouldn't allow suspending the user themselves", async () => {
const { admin } = await seed();
const res = await server.post('/api/user.suspend', {
body: {
token: admin.getJwtToken(),
user: admin.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/user.suspend', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});
describe('#user.activate', async () => {
it('should activate a suspended user', async () => {
const { admin, user } = await seed();
await user.update({
suspendedById: admin.id,
suspendedAt: new Date(),
});
expect(user.isSuspended).toBe(true);
const res = await server.post('/api/user.activate', {
body: {
token: admin.getJwtToken(),
user: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body).toMatchSnapshot();
});
it('should require admin', async () => {
const { user } = await seed();
const res = await server.post('/api/user.activate', {
body: { token: user.getJwtToken(), user: user.id },
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
});

View File

@ -0,0 +1,18 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'suspendedById', {
type: Sequelize.UUID,
});
await queryInterface.addColumn('users', 'suspendedAt', {
type: Sequelize.DATE,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'suspendedById');
await queryInterface.removeColumn('users', 'suspendedAt');
}
};

View File

@ -63,4 +63,20 @@ Team.prototype.removeAdmin = async function(user: User) {
} }
}; };
Team.prototype.suspendUser = async function(user: User, admin: User) {
if (user.id === admin.id)
throw new Error('Unable to suspend the current user');
return user.update({
suspendedById: admin.id,
suspendedAt: new Date(),
});
};
Team.prototype.activateUser = async function(user: User, admin: User) {
return user.update({
suspendedById: null,
suspendedAt: null,
});
};
export default Team; export default Team;

View File

@ -28,8 +28,14 @@ const User = sequelize.define(
slackId: { type: DataTypes.STRING, allowNull: true, unique: true }, slackId: { type: DataTypes.STRING, allowNull: true, unique: true },
slackData: DataTypes.JSONB, slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault('jwtSecret'), jwtSecret: encryptedFields.vault('jwtSecret'),
suspendedAt: DataTypes.DATE,
}, },
{ {
getterMethods: {
isSuspended() {
return !!this.suspendedAt;
},
},
indexes: [ indexes: [
{ {
fields: ['email'], fields: ['email'],
@ -43,6 +49,10 @@ User.associate = models => {
User.hasMany(models.ApiKey, { as: 'apiKeys' }); User.hasMany(models.ApiKey, { as: 'apiKeys' });
User.hasMany(models.Document, { as: 'documents' }); User.hasMany(models.Document, { as: 'documents' });
User.hasMany(models.View, { as: 'views' }); User.hasMany(models.View, { as: 'views' });
User.belongsTo(models.User, {
as: 'suspendedBy',
foreignKey: 'suspendedById',
});
}; };
// Instance methods // Instance methods

View File

@ -528,32 +528,24 @@ export default function Pricing() {
<Arguments pagination /> <Arguments pagination />
</Method> </Method>
<Method method="team.addAdmin" label="Promote a new admin user"> <Method method="user.promote" label="Promote a new admin user">
<Description> <Description>
Promote a user to be a team admin. This endpoint is only available Promote a user to be a team admin. This endpoint is only available
for admin users. for admin users.
</Description> </Description>
<Arguments pagination> <Arguments pagination>
<Argument <Argument id="id" description="User ID to be promoted" required />
id="user"
description="User ID to be promoted"
required
/>
</Arguments> </Arguments>
</Method> </Method>
<Method method="team.removeAdmin" label="Demote existing admin user"> <Method method="user.demote" label="Demote existing admin user">
<Description> <Description>
Demote existing team admin if there are more than one as one admin Demote existing team admin if there are more than one as one admin
is always required. This endpoint is only available for admin is always required. This endpoint is only available for admin
users. users.
</Description> </Description>
<Arguments pagination> <Arguments pagination>
<Argument <Argument id="id" description="User ID to be demoted" required />
id="user"
description="User ID to be demoted"
required
/>
</Arguments> </Arguments>
</Method> </Method>
</Methods> </Methods>

View File

@ -19,8 +19,13 @@ allow(User, ['update', 'delete'], User, (actor, user) => {
throw new AdminRequiredError(); throw new AdminRequiredError();
}); });
allow(User, ['promote', 'demote'], User, (actor, user) => { allow(
if (!user || user.teamId !== actor.teamId) return false; User,
if (actor.isAdmin) return true; ['promote', 'demote', 'activate', 'suspend'],
throw new AdminRequiredError(); User,
}); (actor, user) => {
if (!user || user.teamId !== actor.teamId) return false;
if (actor.isAdmin) return true;
throw new AdminRequiredError();
}
);

View File

@ -30,6 +30,7 @@ export default (
if (options.includeDetails) { if (options.includeDetails) {
userData.isAdmin = user.isAdmin; userData.isAdmin = user.isAdmin;
userData.isSuspended = user.isSuspended;
userData.email = user.email; userData.email = user.email;
} }