feat: added adonisjs bouncer

This commit is contained in:
Sergey Yarkov 2021-11-06 18:27:44 +03:00
parent 00c2db0697
commit 077f1f91a6
10 changed files with 221 additions and 52 deletions

View File

@ -5,7 +5,8 @@
"@adonisjs/core/commands", "@adonisjs/core/commands",
"@adonisjs/repl/build/commands", "@adonisjs/repl/build/commands",
"@adonisjs/lucid/build/commands", "@adonisjs/lucid/build/commands",
"@adonisjs/mail/build/commands" "@adonisjs/mail/build/commands",
"@adonisjs/bouncer/build/commands"
], ],
"exceptionHandlerNamespace": "App/Exceptions/Handler", "exceptionHandlerNamespace": "App/Exceptions/Handler",
"namespaces": { "namespaces": {
@ -26,7 +27,8 @@
"preloads": [ "preloads": [
"./routes/index", "./routes/index",
"./start/kernel", "./start/kernel",
"./start/event" "./start/event",
"./start/bouncer"
], ],
"providers": [ "providers": [
"./providers/AppProvider", "./providers/AppProvider",
@ -37,7 +39,8 @@
"@adonisjs/redis", "@adonisjs/redis",
"@adonisjs/mail", "@adonisjs/mail",
"@adonisjs/view", "@adonisjs/view",
"@adonisjs/attachment-lite" "@adonisjs/attachment-lite",
"@adonisjs/bouncer"
], ],
"aceProviders": [ "aceProviders": [
"@adonisjs/repl" "@adonisjs/repl"

View File

@ -278,6 +278,42 @@
], ],
"aliases": [], "aliases": [],
"flags": [] "flags": []
},
"make:policy": {
"settings": {},
"commandPath": "@adonisjs/bouncer/build/commands/MakePolicy",
"commandName": "make:policy",
"description": "Make a new bouncer policy",
"args": [
{
"type": "string",
"propertyName": "name",
"name": "name",
"required": true,
"description": "Name of the policy to create"
}
],
"aliases": [],
"flags": [
{
"name": "resource-model",
"propertyName": "resourceModel",
"type": "string",
"description": "Name of the resource model to authorize"
},
{
"name": "user-model",
"propertyName": "userModel",
"type": "string",
"description": "Name of the user model to be authorized"
},
{
"name": "actions",
"propertyName": "actions",
"type": "array",
"description": "Actions to implement"
}
]
} }
}, },
"aliases": {} "aliases": {}

View File

@ -78,7 +78,7 @@ export default class UsersController extends BaseController {
public async update(ctx: HttpContextContract) { public async update(ctx: HttpContextContract) {
const payload = await ctx.request.validate(UpdateUserValidator); const payload = await ctx.request.validate(UpdateUserValidator);
const result = await this.userService.updateUser(ctx.params.id, payload); const result = await this.userService.updateUser(ctx.params.id, payload, ctx);
if (!result.success && result.error) { if (!result.success && result.error) {
throw new Exception(result.message, result.status, result.error.code); throw new Exception(result.message, result.status, result.error.code);

View File

@ -134,38 +134,32 @@ export default class UserRepository {
*/ */
public async update(id: number | string, data: UpdateUserValidator['schema']['props']): Promise<User | null> { public async update(id: number | string, data: UpdateUserValidator['schema']['props']): Promise<User | null> {
const user = await this.User.query().preload('contacts').preload('roles').where('id', id).first(); const user = await this.User.query().preload('contacts').preload('roles').where('id', id).first();
const { role: roleSlug, ...updatedFields } = data; const { role, ...updatedFields } = data;
if (user) { if (user) {
/** await user.merge(updatedFields).save();
* Update fields
*/
user.merge(updatedFields);
/**
* Attach new single role
*/
if (roleSlug) {
const role = await this.Role.query().where('slug', roleSlug).first();
if (role) {
await user.related('roles').detach();
await user.related('roles').attach([role.id]);
await user.load('roles');
}
}
/**
* Save updated user in database
*/
await user.save();
return user; return user;
} }
return null; return null;
} }
/**
* Rewrite all user roles
*
* @param user User
* @param roles Updated roles
* @returns Updated user roles
*/
// eslint-disable-next-line class-methods-use-this
public async updateRoles(user: User, roles: Role[]): Promise<Role[]> {
await user.related('roles').detach();
await user.related('roles').attach(roles.map(role => role.id));
await user.load('roles');
return user.roles.map(role => role);
}
/** /**
* Delete user * Delete user
* *

View File

@ -27,8 +27,6 @@ import UserRepository from 'App/Repositories/UserRepository';
*/ */
import CreateUserValidator from 'App/Validators/User/CreateUserValidator'; import CreateUserValidator from 'App/Validators/User/CreateUserValidator';
import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator'; import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator';
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
import RoleHelper from 'App/Helpers/RoleHelper';
@inject() @inject()
export default class UserService { export default class UserService {
@ -97,19 +95,12 @@ export default class UserService {
* @returns Response * @returns Response
*/ */
public async createUser(data: CreateUserValidator['schema']['props'], ctx: HttpContextContract): Promise<IResponse> { public async createUser(data: CreateUserValidator['schema']['props'], ctx: HttpContextContract): Promise<IResponse> {
const user = await ctx.auth.use('api').authenticate();
/**
* Load current user roles
*/
await user.load('roles');
const role = await this.roleRepository.getBySlug(data.role); const role = await this.roleRepository.getBySlug(data.role);
/** /**
* Cannot find role * Cannot find role
*/ */
if (role === null) { if (!role) {
return { return {
success: false, success: false,
status: HttpStatusEnum.NOT_FOUND, status: HttpStatusEnum.NOT_FOUND,
@ -121,16 +112,10 @@ export default class UserService {
}; };
} }
/* TODO need to check the role of the authenticated user before creating a new user,
so we can may be move this into a separate middleware
*/
/** /**
* Check for authenticaded user roles * Check user permissions
*/ */
if ( if (await ctx.bouncer.denies('manageUserRole', role)) {
(role.slug === RoleEnum.ADMIN || role.slug === RoleEnum.TEACHER) &&
!RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN])
) {
return { return {
success: false, success: false,
status: HttpStatusEnum.FORBIDDEN, status: HttpStatusEnum.FORBIDDEN,
@ -145,19 +130,19 @@ export default class UserService {
/** /**
* Create new user * Create new user
*/ */
const createdUser = await this.userRepository.create(data); const user = await this.userRepository.create(data);
/** /**
* Attach role * Attach role
*/ */
await createdUser.related('roles').attach([role.id]); await user.related('roles').attach([role.id]);
await createdUser.load('roles'); await user.load('roles');
return { return {
success: true, success: true,
status: HttpStatusEnum.CREATED, status: HttpStatusEnum.CREATED,
message: 'User created.', message: 'User created.',
data: createdUser, data: user,
}; };
} }
@ -168,7 +153,11 @@ export default class UserService {
* @param data Data to update * @param data Data to update
* @returns Response * @returns Response
*/ */
public async updateUser(id: string | number, data: UpdateUserValidator['schema']['props']): Promise<IResponse> { public async updateUser(
id: string | number,
data: UpdateUserValidator['schema']['props'],
ctx: HttpContextContract
): Promise<IResponse> {
const user = await this.userRepository.update(id, data); const user = await this.userRepository.update(id, data);
if (!user) { if (!user) {
@ -183,6 +172,29 @@ export default class UserService {
}; };
} }
/**
* Update user role
*/
if (data.role) {
const role = await this.roleRepository.getBySlug(data.role);
if (role) {
if (await ctx.bouncer.denies('manageUserRole', role)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'You dont have permissions to permorm that action',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
await this.userRepository.updateRoles(user, [role]);
}
}
return { return {
success: true, success: true,
status: HttpStatusEnum.OK, status: HttpStatusEnum.OK,

16
contracts/bouncer.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* Contract source: https://git.io/Jte3v
*
* Feel free to let us know via PR, if you find something broken in this config
* file.
*/
import { actions, policies } from '../start/bouncer';
declare module '@ioc:Adonis/Addons/Bouncer' {
type ApplicationActions = ExtractActionsTypes<typeof actions>;
type ApplicationPolicies = ExtractPoliciesTypes<typeof policies>;
interface ActionsList extends ApplicationActions {}
interface PoliciesList extends ApplicationPolicies {}
}

39
package-lock.json generated
View File

@ -285,6 +285,45 @@
} }
} }
}, },
"@adonisjs/bouncer": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@adonisjs/bouncer/-/bouncer-2.2.5.tgz",
"integrity": "sha512-f060bMQwjJBAQ2pTtUrCbJ3n3/J3nQvzdrpWpWSEzdn8bdZALGh2R1nIhH0cVh8lMQuXzowo9ojxjWGFI0LfmQ==",
"requires": {
"@poppinss/utils": "^3.2.0"
},
"dependencies": {
"@poppinss/utils": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-3.3.1.tgz",
"integrity": "sha512-k4MFt+4JhOWJZ9D2xpEcG/mpZyXVXYT+dSOg83vHK1xhXl+7r0IYBXRKWX2+To7/90KJaWlwpcdCAalXE8Debg==",
"requires": {
"@types/bytes": "^3.1.1",
"@types/he": "^1.1.2",
"buffer-alloc": "^1.2.0",
"bytes": "^3.1.0",
"change-case": "^4.1.2",
"cuid": "^2.1.8",
"flattie": "^1.1.0",
"fs-readdir-recursive": "^1.1.0",
"he": "^1.2.0",
"kind-of": "^6.0.3",
"lodash": "^4.17.21",
"ms": "^2.1.3",
"pluralize": "^8.0.0",
"require-all": "^3.0.0",
"resolve-from": "^5.0.0",
"slugify": "^1.6.1",
"truncatise": "0.0.8"
}
},
"slugify": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.1.tgz",
"integrity": "sha512-5ofqMTbetNhxlzjYYLBaZFQd6oiTuSkQlyfPEFIMwgUABlZQ0hbk5xIV9Ydd5jghWeRoO7GkiJliUvTpLOjNRA=="
}
}
},
"@adonisjs/config": { "@adonisjs/config": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-3.0.5.tgz",

View File

@ -34,6 +34,7 @@
"dependencies": { "dependencies": {
"@adonisjs/attachment-lite": "^1.0.1", "@adonisjs/attachment-lite": "^1.0.1",
"@adonisjs/auth": "^8.0.10", "@adonisjs/auth": "^8.0.10",
"@adonisjs/bouncer": "^2.2.5",
"@adonisjs/core": "^5.4.0", "@adonisjs/core": "^5.4.0",
"@adonisjs/drive": "^2.0.7", "@adonisjs/drive": "^2.0.7",
"@adonisjs/lucid": "^16.2.1", "@adonisjs/lucid": "^16.2.1",

67
start/bouncer.ts Normal file
View File

@ -0,0 +1,67 @@
/**
* Contract source: https://git.io/Jte3T
*
* Feel free to let us know via PR, if you find something broken in this config
* file.
*/
import Bouncer from '@ioc:Adonis/Addons/Bouncer';
import RoleEnum from 'App/Datatypes/Enums/RoleEnum';
import RoleHelper from 'App/Helpers/RoleHelper';
import Role from 'App/Models/Role';
import User from 'App/Models/User';
/*
|--------------------------------------------------------------------------
| Bouncer Actions
|--------------------------------------------------------------------------
|
| Actions allows you to separate your application business logic from the
| authorization logic. Feel free to make use of policies when you find
| yourself creating too many actions
|
| You can define an action using the `.define` method on the Bouncer object
| as shown in the following example
|
| ```
| Bouncer.define('deletePost', (user: User, post: Post) => {
| return post.user_id === user.id
| })
| ```
|
|****************************************************************
| NOTE: Always export the "actions" const from this file
|****************************************************************
*/
export const { actions } = Bouncer.define('manageUserRole', async (user: User, role: Role) => {
await user.load('roles');
return !(
(role.slug === RoleEnum.ADMIN || role.slug === RoleEnum.TEACHER) &&
!RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN])
);
});
/*
|--------------------------------------------------------------------------
| Bouncer Policies
|--------------------------------------------------------------------------
|
| Policies are self contained actions for a given resource. For example: You
| can create a policy for a "User" resource, one policy for a "Post" resource
| and so on.
|
| The "registerPolicies" accepts a unique policy name and a function to lazy
| import the policy
|
| ```
| Bouncer.registerPolicies({
| UserPolicy: () => import('App/Policies/User'),
| PostPolicy: () => import('App/Policies/Post')
| })
| ```
|
|****************************************************************
| NOTE: Always export the "policies" const from this file
|****************************************************************
*/
export const { policies } = Bouncer.registerPolicies({});

View File

@ -34,7 +34,8 @@
"@adonisjs/redis", "@adonisjs/redis",
"@adonisjs/mail", "@adonisjs/mail",
"@adonisjs/view", "@adonisjs/view",
"@adonisjs/attachment-lite" "@adonisjs/attachment-lite",
"@adonisjs/bouncer"
] ]
} }
} }