From 077f1f91a6187166abea5304e2e6bcb2070762e2 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Sat, 6 Nov 2021 18:27:44 +0300 Subject: [PATCH] feat: added adonisjs bouncer --- .adonisrc.json | 9 ++- ace-manifest.json | 36 ++++++++++ .../Http/Api/v1/UsersController.ts | 2 +- app/Repositories/UserRepository.ts | 42 +++++------- app/Services/UserService.ts | 58 +++++++++------- contracts/bouncer.ts | 16 +++++ package-lock.json | 39 +++++++++++ package.json | 1 + start/bouncer.ts | 67 +++++++++++++++++++ tsconfig.json | 3 +- 10 files changed, 221 insertions(+), 52 deletions(-) create mode 100644 contracts/bouncer.ts create mode 100644 start/bouncer.ts diff --git a/.adonisrc.json b/.adonisrc.json index b672ecd..837ffa2 100644 --- a/.adonisrc.json +++ b/.adonisrc.json @@ -5,7 +5,8 @@ "@adonisjs/core/commands", "@adonisjs/repl/build/commands", "@adonisjs/lucid/build/commands", - "@adonisjs/mail/build/commands" + "@adonisjs/mail/build/commands", + "@adonisjs/bouncer/build/commands" ], "exceptionHandlerNamespace": "App/Exceptions/Handler", "namespaces": { @@ -26,7 +27,8 @@ "preloads": [ "./routes/index", "./start/kernel", - "./start/event" + "./start/event", + "./start/bouncer" ], "providers": [ "./providers/AppProvider", @@ -37,7 +39,8 @@ "@adonisjs/redis", "@adonisjs/mail", "@adonisjs/view", - "@adonisjs/attachment-lite" + "@adonisjs/attachment-lite", + "@adonisjs/bouncer" ], "aceProviders": [ "@adonisjs/repl" diff --git a/ace-manifest.json b/ace-manifest.json index b53470f..0b1c0f2 100644 --- a/ace-manifest.json +++ b/ace-manifest.json @@ -278,6 +278,42 @@ ], "aliases": [], "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": {} diff --git a/app/Controllers/Http/Api/v1/UsersController.ts b/app/Controllers/Http/Api/v1/UsersController.ts index 6dce72a..3f03c28 100644 --- a/app/Controllers/Http/Api/v1/UsersController.ts +++ b/app/Controllers/Http/Api/v1/UsersController.ts @@ -78,7 +78,7 @@ export default class UsersController extends BaseController { public async update(ctx: HttpContextContract) { 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) { throw new Exception(result.message, result.status, result.error.code); diff --git a/app/Repositories/UserRepository.ts b/app/Repositories/UserRepository.ts index 321354f..b312df1 100644 --- a/app/Repositories/UserRepository.ts +++ b/app/Repositories/UserRepository.ts @@ -134,38 +134,32 @@ export default class UserRepository { */ public async update(id: number | string, data: UpdateUserValidator['schema']['props']): Promise { 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) { - /** - * 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(); - + await user.merge(updatedFields).save(); return user; } 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 { + 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 * diff --git a/app/Services/UserService.ts b/app/Services/UserService.ts index d907185..97b11b6 100644 --- a/app/Services/UserService.ts +++ b/app/Services/UserService.ts @@ -27,8 +27,6 @@ import UserRepository from 'App/Repositories/UserRepository'; */ import CreateUserValidator from 'App/Validators/User/CreateUserValidator'; import UpdateUserValidator from 'App/Validators/User/UpdateUserValidator'; -import RoleEnum from 'App/Datatypes/Enums/RoleEnum'; -import RoleHelper from 'App/Helpers/RoleHelper'; @inject() export default class UserService { @@ -97,19 +95,12 @@ export default class UserService { * @returns Response */ public async createUser(data: CreateUserValidator['schema']['props'], ctx: HttpContextContract): Promise { - const user = await ctx.auth.use('api').authenticate(); - - /** - * Load current user roles - */ - await user.load('roles'); - const role = await this.roleRepository.getBySlug(data.role); /** * Cannot find role */ - if (role === null) { + if (!role) { return { success: false, 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 ( - (role.slug === RoleEnum.ADMIN || role.slug === RoleEnum.TEACHER) && - !RoleHelper.userContainRoles(user.roles, [RoleEnum.ADMIN]) - ) { + if (await ctx.bouncer.denies('manageUserRole', role)) { return { success: false, status: HttpStatusEnum.FORBIDDEN, @@ -145,19 +130,19 @@ export default class UserService { /** * Create new user */ - const createdUser = await this.userRepository.create(data); + const user = await this.userRepository.create(data); /** * Attach role */ - await createdUser.related('roles').attach([role.id]); - await createdUser.load('roles'); + await user.related('roles').attach([role.id]); + await user.load('roles'); return { success: true, status: HttpStatusEnum.CREATED, message: 'User created.', - data: createdUser, + data: user, }; } @@ -168,7 +153,11 @@ export default class UserService { * @param data Data to update * @returns Response */ - public async updateUser(id: string | number, data: UpdateUserValidator['schema']['props']): Promise { + public async updateUser( + id: string | number, + data: UpdateUserValidator['schema']['props'], + ctx: HttpContextContract + ): Promise { const user = await this.userRepository.update(id, data); 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 { success: true, status: HttpStatusEnum.OK, diff --git a/contracts/bouncer.ts b/contracts/bouncer.ts new file mode 100644 index 0000000..c14281a --- /dev/null +++ b/contracts/bouncer.ts @@ -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; + type ApplicationPolicies = ExtractPoliciesTypes; + + interface ActionsList extends ApplicationActions {} + interface PoliciesList extends ApplicationPolicies {} +} diff --git a/package-lock.json b/package-lock.json index cf855f2..1dcac5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-3.0.5.tgz", diff --git a/package.json b/package.json index ff6c4c4..33602ac 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dependencies": { "@adonisjs/attachment-lite": "^1.0.1", "@adonisjs/auth": "^8.0.10", + "@adonisjs/bouncer": "^2.2.5", "@adonisjs/core": "^5.4.0", "@adonisjs/drive": "^2.0.7", "@adonisjs/lucid": "^16.2.1", diff --git a/start/bouncer.ts b/start/bouncer.ts new file mode 100644 index 0000000..78c32bd --- /dev/null +++ b/start/bouncer.ts @@ -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({}); diff --git a/tsconfig.json b/tsconfig.json index ea9c925..185f317 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,7 +34,8 @@ "@adonisjs/redis", "@adonisjs/mail", "@adonisjs/view", - "@adonisjs/attachment-lite" + "@adonisjs/attachment-lite", + "@adonisjs/bouncer" ] } }