From eca8909c8f5d23182531077ed8a9ee2fd5b8c5b6 Mon Sep 17 00:00:00 2001 From: Ramesh Date: Thu, 4 Jan 2024 16:53:40 +0545 Subject: [PATCH] feat: add role based access control (#564) --- .github/workflows/test.yml | 2 +- packages/user/src/constants.ts | 28 ++++ packages/user/src/index.ts | 16 ++- packages/user/src/lib/hasUserPermission.ts | 49 +++++++ .../user/src/mercurius-auth/authPlugin.ts | 50 +++++++ .../src/mercurius-auth/hasPermissionPlugin.ts | 53 ++++++++ packages/user/src/mercurius-auth/plugin.ts | 47 +------ .../user/src/middlewares/hasPermission.ts | 38 ++++++ .../user/src/model/invitations/controller.ts | 24 +++- .../user/src/model/permissions/controller.ts | 22 +++ .../permissions/handlers/getPermissions.ts | 22 +++ .../src/model/permissions/handlers/index.ts | 5 + .../user/src/model/permissions/resolver.ts | 31 +++++ packages/user/src/model/roles/controller.ts | 46 +++++++ .../src/model/roles/handlers/createRole.ts | 29 ++++ .../model/roles/handlers/getPermissions.ts | 31 +++++ .../user/src/model/roles/handlers/getRoles.ts | 25 ++++ .../user/src/model/roles/handlers/index.ts | 11 ++ .../model/roles/handlers/updatePermissions.ts | 36 +++++ packages/user/src/model/roles/resolver.ts | 125 ++++++++++++++++++ packages/user/src/model/roles/service.ts | 59 +++++++++ packages/user/src/model/users/controller.ts | 18 ++- .../user/src/model/users/handlers/disable.ts | 22 --- .../user/src/model/users/handlers/enable.ts | 22 --- packages/user/src/plugin.ts | 3 + 25 files changed, 719 insertions(+), 95 deletions(-) create mode 100644 packages/user/src/lib/hasUserPermission.ts create mode 100644 packages/user/src/mercurius-auth/authPlugin.ts create mode 100644 packages/user/src/mercurius-auth/hasPermissionPlugin.ts create mode 100644 packages/user/src/middlewares/hasPermission.ts create mode 100644 packages/user/src/model/permissions/controller.ts create mode 100644 packages/user/src/model/permissions/handlers/getPermissions.ts create mode 100644 packages/user/src/model/permissions/handlers/index.ts create mode 100644 packages/user/src/model/permissions/resolver.ts create mode 100644 packages/user/src/model/roles/controller.ts create mode 100644 packages/user/src/model/roles/handlers/createRole.ts create mode 100644 packages/user/src/model/roles/handlers/getPermissions.ts create mode 100644 packages/user/src/model/roles/handlers/getRoles.ts create mode 100644 packages/user/src/model/roles/handlers/index.ts create mode 100644 packages/user/src/model/roles/handlers/updatePermissions.ts create mode 100644 packages/user/src/model/roles/resolver.ts create mode 100644 packages/user/src/model/roles/service.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c515abf2f..a8de0faec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x] + node-version: [16.x, 18.x, 20.x] steps: - uses: actions/checkout@v3 name: Use node ${{ matrix.node-version }} diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index bec7884c5..f31ba00ad 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -12,6 +12,7 @@ const TABLE_INVITATIONS = "invitations"; // Users const RESET_PASSWORD_PATH = "/reset-password"; const ROLE_ADMIN = "ADMIN"; +const ROLE_SUPER_ADMIN = "SUPER_ADMIN"; const ROLE_USER = "USER"; const ROUTE_CHANGE_PASSWORD = "/change_password"; const ROUTE_SIGNUP_ADMIN = "/signup/admin"; @@ -21,17 +22,40 @@ const ROUTE_USERS_DISABLE = "/users/:id/disable"; const ROUTE_USERS_ENABLE = "/users/:id/enable"; const TABLE_USERS = "users"; +// Roles +const ROUTE_ROLES = "/roles"; +const ROUTE_ROLES_PERMISSIONS = "/roles/permissions"; + +// Permissions +const ROUTE_PERMISSIONS = "/permissions"; + // Email verification const EMAIL_VERIFICATION_MODE = "REQUIRED"; const EMAIL_VERIFICATION_PATH = "/verify-email"; +const PERMISSIONS_INVITIATIONS_CREATE = "invitations:create"; +const PERMISSIONS_INVITIATIONS_LIST = "invitations:list"; +const PERMISSIONS_INVITIATIONS_RESEND = "invitations:resend"; +const PERMISSIONS_INVITIATIONS_REVOKE = "invitations:revoke"; + +const PERMISSIONS_USERS_DISABLE = "users:disable"; +const PERMISSIONS_USERS_ENABLE = "users:enable"; +const PERMISSIONS_USERS_LIST = "users:enable"; + export { EMAIL_VERIFICATION_MODE, EMAIL_VERIFICATION_PATH, INVITATION_ACCEPT_PATH, INVITATION_EXPIRE_AFTER_IN_DAYS, + PERMISSIONS_INVITIATIONS_LIST, + PERMISSIONS_INVITIATIONS_RESEND, + PERMISSIONS_INVITIATIONS_REVOKE, + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, RESET_PASSWORD_PATH, ROLE_ADMIN, + ROLE_SUPER_ADMIN, ROLE_USER, ROUTE_CHANGE_PASSWORD, ROUTE_INVITATIONS, @@ -41,6 +65,10 @@ export { ROUTE_INVITATIONS_RESEND, ROUTE_INVITATIONS_REVOKE, ROUTE_ME, + ROUTE_PERMISSIONS, + ROUTE_ROLES, + PERMISSIONS_INVITIATIONS_CREATE, + ROUTE_ROLES_PERMISSIONS, ROUTE_SIGNUP_ADMIN, ROUTE_USERS, ROUTE_USERS_DISABLE, diff --git a/packages/user/src/index.ts b/packages/user/src/index.ts index e5259ecea..5c057df88 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -1,5 +1,6 @@ import "@dzangolab/fastify-mercurius"; +import hasPermission from "./middlewares/hasPermission"; import invitationHandlers from "./model/invitations/handlers"; import userHandlers from "./model/users/handlers"; @@ -8,6 +9,12 @@ import type { IsEmailOptions, StrongPasswordOptions, User } from "./types"; import type { Invitation } from "./types/invitation"; import type { FastifyRequest } from "fastify"; +declare module "fastify" { + interface FastifyInstance { + hasPermission: typeof hasPermission; + } +} + declare module "mercurius" { interface MercuriusContext { roles: string[] | undefined; @@ -52,6 +59,7 @@ declare module "@dzangolab/fastify-config" { }; }; password?: StrongPasswordOptions; + permissions?: string[]; supertokens: SupertokensConfig; table?: { name?: string; @@ -83,7 +91,12 @@ export { default as invitationResolver } from "./model/invitations/resolver"; export { default as InvitationSqlFactory } from "./model/invitations/sqlFactory"; export { default as InvitationService } from "./model/invitations/service"; export { default as invitationRoutes } from "./model/invitations/controller"; -// [DU 2023-AUG-07] use formatDate from "@dzangolab/fastify-slonik" package +export { default as permissionResolver } from "./model/permissions/resolver"; +export { default as permissionRoutes } from "./model/permissions/controller"; +export { default as ROleService } from "./model/roles/service"; +export { default as roleResolver } from "./model/roles/resolver"; +export { default as roleRoutes } from "./model/roles/controller"; +// [DU 2023-AUG-07] use formatDate from "@dzangolab/fastify-slonik" package export { formatDate } from "@dzangolab/fastify-slonik"; export { default as computeInvitationExpiresAt } from "./lib/computeInvitationExpiresAt"; export { default as getOrigin } from "./lib/getOrigin"; @@ -95,6 +108,7 @@ export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; export { default as validateEmail } from "./validator/email"; export { default as validatePassword } from "./validator/password"; +export { default as hasUserPermission } from "./lib/hasUserPermission"; export * from "./constants"; diff --git a/packages/user/src/lib/hasUserPermission.ts b/packages/user/src/lib/hasUserPermission.ts new file mode 100644 index 000000000..6c1d8c19e --- /dev/null +++ b/packages/user/src/lib/hasUserPermission.ts @@ -0,0 +1,49 @@ +import UserRoles from "supertokens-node/recipe/userroles"; + +import { ROLE_SUPER_ADMIN } from "../constants"; + +import type { FastifyInstance } from "fastify"; + +const getPermissions = async (roles: string[]) => { + let permissions: string[] = []; + + for (const role of roles) { + const response = await UserRoles.getPermissionsForRole(role); + + if (response.status === "OK") { + permissions = [...new Set([...permissions, ...response.permissions])]; + } + } + + return permissions; +}; + +const hasUserPermission = async ( + fastify: FastifyInstance, + userId: string, + permission: string +): Promise => { + const permissions = fastify.config.user.permissions; + + // Allow if provided permission is not defined + if (!permissions || !permissions.includes(permission)) { + return true; + } + + const { roles } = await UserRoles.getRolesForUser(userId); + + // Allow if user has super admin role + if (roles && roles.includes(ROLE_SUPER_ADMIN)) { + return true; + } + + const rolePermissions = await getPermissions(roles); + + if (!rolePermissions || !rolePermissions.includes(permission)) { + return false; + } + + return true; +}; + +export default hasUserPermission; diff --git a/packages/user/src/mercurius-auth/authPlugin.ts b/packages/user/src/mercurius-auth/authPlugin.ts new file mode 100644 index 000000000..913276bab --- /dev/null +++ b/packages/user/src/mercurius-auth/authPlugin.ts @@ -0,0 +1,50 @@ +import FastifyPlugin from "fastify-plugin"; +import mercurius from "mercurius"; +import mercuriusAuth from "mercurius-auth"; +import emailVerificaiton from "supertokens-node/recipe/emailverification"; + +import type { FastifyInstance } from "fastify"; + +const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { + await fastify.register(mercuriusAuth, { + async applyPolicy(authDirectiveAST, parent, arguments_, context) { + if (!context.user) { + return new mercurius.ErrorWithProps("unauthorized", {}, 401); + } + + if (context.user.disabled) { + return new mercurius.ErrorWithProps("user is disabled", {}, 401); + } + + if ( + fastify.config.user.features?.signUp?.emailVerification && + !(await emailVerificaiton.isEmailVerified(context.user.id)) + ) { + // Added the claim validation errors to match with rest endpoint + // response for email verification + return new mercurius.ErrorWithProps( + "invalid claim", + { + claimValidationErrors: [ + { + id: "st-ev", + reason: { + message: "wrong value", + expectedValue: true, + actualValue: false, + }, + }, + ], + }, + 403 + ); + } + + return true; + }, + + authDirective: "auth", + }); +}); + +export default plugin; diff --git a/packages/user/src/mercurius-auth/hasPermissionPlugin.ts b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts new file mode 100644 index 000000000..dfa10b07a --- /dev/null +++ b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts @@ -0,0 +1,53 @@ +import FastifyPlugin from "fastify-plugin"; +import mercurius from "mercurius"; +import mercuriusAuth from "mercurius-auth"; + +import hasUserPermission from "../lib/hasUserPermission"; + +import type { FastifyInstance } from "fastify"; + +const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { + await fastify.register(mercuriusAuth, { + applyPolicy: async (authDirectiveAST, parent, arguments_, context) => { + const permission = authDirectiveAST.arguments.find( + (argument: { name: { value: string } }) => + argument.name.value === "permission" + ).value.value; + + if (!context.user) { + return new mercurius.ErrorWithProps("unauthorized", {}, 401); + } + + const hasPermission = await hasUserPermission( + context.app, + context.user?.id, + permission + ); + + if (!hasPermission) { + // Added the claim validation errors to match with rest endpoint + // response for hasPermission + return new mercurius.ErrorWithProps( + "invalid claim", + { + claimValidationErrors: [ + { + id: "st-perm", + reason: { + message: "Not have enough permission", + expectedToInclude: permission, + }, + }, + ], + }, + 403 + ); + } + + return true; + }, + authDirective: "hasPermission", + }); +}); + +export default plugin; diff --git a/packages/user/src/mercurius-auth/plugin.ts b/packages/user/src/mercurius-auth/plugin.ts index 8c854745d..6b9e0145a 100644 --- a/packages/user/src/mercurius-auth/plugin.ts +++ b/packages/user/src/mercurius-auth/plugin.ts @@ -1,7 +1,7 @@ import FastifyPlugin from "fastify-plugin"; -import mercurius from "mercurius"; -import mercuriusAuth from "mercurius-auth"; -import emailVerificaiton from "supertokens-node/recipe/emailverification"; + +import authPlugin from "./authPlugin"; +import hasPermissionPlugin from "./hasPermissionPlugin"; import type { FastifyInstance } from "fastify"; @@ -9,45 +9,8 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { const mercuriusConfig = fastify.config.mercurius; if (mercuriusConfig.enabled) { - await fastify.register(mercuriusAuth, { - async applyPolicy(authDirectiveAST, parent, arguments_, context) { - if (!context.user) { - return new mercurius.ErrorWithProps("unauthorized", {}, 401); - } - - if (context.user.disabled) { - return new mercurius.ErrorWithProps("user is disabled", {}, 401); - } - - if ( - fastify.config.user.features?.signUp?.emailVerification && - !(await emailVerificaiton.isEmailVerified(context.user.id)) - ) { - // Added the claim validation errors to match with rest endpoint - // response for email verification - return new mercurius.ErrorWithProps( - "invalid claim", - { - claimValidationErrors: [ - { - id: "st-ev", - reason: { - message: "wrong value", - expectedValue: true, - actualValue: false, - }, - }, - ], - }, - 403 - ); - } - - return true; - }, - - authDirective: "auth", - }); + await fastify.register(hasPermissionPlugin); + await fastify.register(authPlugin); } }); diff --git a/packages/user/src/middlewares/hasPermission.ts b/packages/user/src/middlewares/hasPermission.ts new file mode 100644 index 000000000..3c89afdb1 --- /dev/null +++ b/packages/user/src/middlewares/hasPermission.ts @@ -0,0 +1,38 @@ +import { Error as STError } from "supertokens-node/recipe/session"; +import UserRoles from "supertokens-node/recipe/userroles"; + +import hasUserPermission from "../lib/hasUserPermission"; + +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const hasPermission = + (permission: string) => + async (request: SessionRequest): Promise => { + const userId = request.session?.getUserId(); + + if (!userId) { + throw new STError({ + type: "UNAUTHORISED", + message: "unauthorised", + }); + } + + if (!(await hasUserPermission(request.server, userId, permission))) { + // this error tells SuperTokens to return a 403 http response. + throw new STError({ + type: "INVALID_CLAIMS", + message: "Not have enough permission", + payload: [ + { + id: UserRoles.PermissionClaim.key, + reason: { + message: "Not have enough permission", + expectedToInclude: permission, + }, + }, + ], + }); + } + }; + +export default hasPermission; diff --git a/packages/user/src/model/invitations/controller.ts b/packages/user/src/model/invitations/controller.ts index fb27ef370..7b6b61c16 100644 --- a/packages/user/src/model/invitations/controller.ts +++ b/packages/user/src/model/invitations/controller.ts @@ -1,5 +1,9 @@ import handlers from "./handlers"; import { + PERMISSIONS_INVITIATIONS_CREATE, + PERMISSIONS_INVITIATIONS_LIST, + PERMISSIONS_INVITIATIONS_RESEND, + PERMISSIONS_INVITIATIONS_REVOKE, ROUTE_INVITATIONS, ROUTE_INVITATIONS_ACCEPT, ROUTE_INVITATIONS_CREATE, @@ -20,7 +24,10 @@ const plugin = async ( fastify.get( ROUTE_INVITATIONS, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_INVITIATIONS_LIST), + ], }, handlersConfig?.list || handlers.listInvitation ); @@ -28,7 +35,10 @@ const plugin = async ( fastify.post( ROUTE_INVITATIONS_CREATE, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_INVITIATIONS_CREATE), + ], }, handlersConfig?.create || handlers.createInvitation ); @@ -46,7 +56,10 @@ const plugin = async ( fastify.put( ROUTE_INVITATIONS_REVOKE, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_INVITIATIONS_REVOKE), + ], }, handlersConfig?.revoke || handlers.revokeInvitation ); @@ -54,7 +67,10 @@ const plugin = async ( fastify.post( ROUTE_INVITATIONS_RESEND, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_INVITIATIONS_RESEND), + ], }, handlersConfig?.resend || handlers.resendInvitation ); diff --git a/packages/user/src/model/permissions/controller.ts b/packages/user/src/model/permissions/controller.ts new file mode 100644 index 000000000..2ab646cfe --- /dev/null +++ b/packages/user/src/model/permissions/controller.ts @@ -0,0 +1,22 @@ +import handlers from "./handlers"; +import { ROUTE_PERMISSIONS } from "../../constants"; + +import type { FastifyInstance } from "fastify"; + +const plugin = async ( + fastify: FastifyInstance, + options: unknown, + done: () => void +) => { + fastify.get( + ROUTE_PERMISSIONS, + { + preHandler: [fastify.verifySession()], + }, + handlers.getPermissions + ); + + done(); +}; + +export default plugin; diff --git a/packages/user/src/model/permissions/handlers/getPermissions.ts b/packages/user/src/model/permissions/handlers/getPermissions.ts new file mode 100644 index 000000000..5cdda3d19 --- /dev/null +++ b/packages/user/src/model/permissions/handlers/getPermissions.ts @@ -0,0 +1,22 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { + const { config, log } = request; + + try { + const permissions: string[] = config.user.permissions || []; + + reply.send({ permissions }); + } catch (error) { + log.error(error); + reply.status(500); + + reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default getPermissions; diff --git a/packages/user/src/model/permissions/handlers/index.ts b/packages/user/src/model/permissions/handlers/index.ts new file mode 100644 index 000000000..b0b80ee21 --- /dev/null +++ b/packages/user/src/model/permissions/handlers/index.ts @@ -0,0 +1,5 @@ +import getPermissions from "./getPermissions"; + +export default { + getPermissions, +}; diff --git a/packages/user/src/model/permissions/resolver.ts b/packages/user/src/model/permissions/resolver.ts new file mode 100644 index 000000000..1709666cc --- /dev/null +++ b/packages/user/src/model/permissions/resolver.ts @@ -0,0 +1,31 @@ +import mercurius from "mercurius"; + +import type { MercuriusContext } from "mercurius"; + +const Query = { + permissions: async ( + parent: unknown, + arguments_: Record, + context: MercuriusContext + ) => { + const { app, config } = context; + + try { + const permissions: string[] = config.user.permissions || []; + + return permissions; + } catch (error) { + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong" + ); + + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, +}; + +export default { Query }; diff --git a/packages/user/src/model/roles/controller.ts b/packages/user/src/model/roles/controller.ts new file mode 100644 index 000000000..964ad9fd8 --- /dev/null +++ b/packages/user/src/model/roles/controller.ts @@ -0,0 +1,46 @@ +import handlers from "./handlers"; +import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; + +import type { FastifyInstance } from "fastify"; + +const plugin = async ( + fastify: FastifyInstance, + options: unknown, + done: () => void +) => { + fastify.get( + ROUTE_ROLES, + { + preHandler: [fastify.verifySession()], + }, + handlers.getRoles + ); + + fastify.get( + ROUTE_ROLES_PERMISSIONS, + { + preHandler: [fastify.verifySession()], + }, + handlers.getPermissions + ); + + fastify.post( + ROUTE_ROLES, + { + preHandler: [fastify.verifySession()], + }, + handlers.createRole + ); + + fastify.put( + ROUTE_ROLES_PERMISSIONS, + { + preHandler: [fastify.verifySession()], + }, + handlers.updatePermissions + ); + + done(); +}; + +export default plugin; diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/createRole.ts new file mode 100644 index 000000000..528ac25a0 --- /dev/null +++ b/packages/user/src/model/roles/handlers/createRole.ts @@ -0,0 +1,29 @@ +import RoleService from "../service"; + +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const createRole = async (request: SessionRequest, reply: FastifyReply) => { + const { body, log } = request; + + const { role } = body as { + role: string; + }; + + try { + const service = new RoleService(); + await service.createRole(role); + + return reply.send({ role }); + } catch (error) { + log.error(error); + reply.status(500); + + return reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default createRole; diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts new file mode 100644 index 000000000..e70ccac79 --- /dev/null +++ b/packages/user/src/model/roles/handlers/getPermissions.ts @@ -0,0 +1,31 @@ +import RoleService from "../service"; + +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { + const { log, query } = request; + let permissions: string[] = []; + + try { + const { role } = query as { role?: string }; + + if (role) { + const service = new RoleService(); + + permissions = await service.getPermissionsForRole(role); + } + + return reply.send({ permissions }); + } catch (error) { + log.error(error); + reply.status(500); + + return reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default getPermissions; diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts new file mode 100644 index 000000000..7429c097d --- /dev/null +++ b/packages/user/src/model/roles/handlers/getRoles.ts @@ -0,0 +1,25 @@ +import RoleService from "../service"; + +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const getRoles = async (request: SessionRequest, reply: FastifyReply) => { + const { log } = request; + + try { + const service = new RoleService(); + const roles = await service.getRoles(); + + return reply.send({ roles }); + } catch (error) { + log.error(error); + reply.status(500); + + return reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default getRoles; diff --git a/packages/user/src/model/roles/handlers/index.ts b/packages/user/src/model/roles/handlers/index.ts new file mode 100644 index 000000000..990facb3b --- /dev/null +++ b/packages/user/src/model/roles/handlers/index.ts @@ -0,0 +1,11 @@ +import createRole from "./createRole"; +import getPermissions from "./getPermissions"; +import getRoles from "./getRoles"; +import updatePermissions from "./updatePermissions"; + +export default { + createRole, + getRoles, + getPermissions, + updatePermissions, +}; diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/updatePermissions.ts new file mode 100644 index 000000000..97bc2f083 --- /dev/null +++ b/packages/user/src/model/roles/handlers/updatePermissions.ts @@ -0,0 +1,36 @@ +import RoleService from "../service"; + +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const updatePermissions = async ( + request: SessionRequest, + reply: FastifyReply +) => { + const { log, body } = request; + + try { + const { role, permissions } = body as { + role: string; + permissions: string[]; + }; + + const service = new RoleService(); + const updatedPermissions = await service.updateRolePermissions( + role, + permissions + ); + + return reply.send({ permissions: updatedPermissions }); + } catch (error) { + log.error(error); + reply.status(500); + + return reply.send({ + status: "ERROR", + message: "Oops! Something went wrong", + }); + } +}; + +export default updatePermissions; diff --git a/packages/user/src/model/roles/resolver.ts b/packages/user/src/model/roles/resolver.ts new file mode 100644 index 000000000..543ee7a5b --- /dev/null +++ b/packages/user/src/model/roles/resolver.ts @@ -0,0 +1,125 @@ +import mercurius from "mercurius"; + +import RoleService from "./service"; + +import type { MercuriusContext } from "mercurius"; + +const Mutation = { + createRole: async ( + parent: unknown, + arguments_: { + role: string; + }, + context: MercuriusContext + ) => { + const { app } = context; + + try { + const service = new RoleService(); + await service.createRole(arguments_.role); + + return arguments_.role; + } catch (error) { + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong" + ); + + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, + updateRolePermissions: async ( + parent: unknown, + arguments_: { + role: string; + permissions: string[]; + }, + context: MercuriusContext + ) => { + const { app } = context; + const { permissions, role } = arguments_; + + try { + const service = new RoleService(); + const updatedPermissions = await service.updateRolePermissions( + role, + permissions + ); + + return updatedPermissions; + } catch (error) { + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong" + ); + + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, +}; + +const Query = { + roles: async ( + parent: unknown, + arguments_: Record, + context: MercuriusContext + ) => { + const { app } = context; + + try { + const service = new RoleService(); + const roles = await service.getRoles(); + + return roles; + } catch (error) { + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong" + ); + + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, + rolePermissions: async ( + parent: unknown, + arguments_: { + role: string; + }, + context: MercuriusContext + ) => { + const { app } = context; + const { role } = arguments_; + let permissions: string[] = []; + + try { + if (role) { + const service = new RoleService(); + + permissions = await service.getPermissionsForRole(role); + } + + return permissions; + } catch (error) { + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong" + ); + + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, +}; + +export default { Mutation, Query }; diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts new file mode 100644 index 000000000..a1ba2ed6f --- /dev/null +++ b/packages/user/src/model/roles/service.ts @@ -0,0 +1,59 @@ +import UserRoles from "supertokens-node/recipe/userroles"; + +class RoleService { + createRole = async (role: string) => { + await UserRoles.createNewRoleOrAddPermissions(role, []); + }; + + getPermissionsForRole = async (role: string): Promise => { + let permissions: string[] = []; + + const response = await UserRoles.getPermissionsForRole(role); + + if (response.status === "OK") { + permissions = response.permissions; + } + + return permissions; + }; + + getRoles = async (): Promise => { + let roles: string[] = []; + + const response = await UserRoles.getAllRoles(); + + if (response.status === "OK") { + roles = response.roles; + } + + return roles; + }; + + updateRolePermissions = async ( + role: string, + permissions: string[] + ): Promise => { + const response = await UserRoles.getPermissionsForRole(role); + + if (response.status === "UNKNOWN_ROLE_ERROR") { + throw new Error("UNKNOWN_ROLE_ERROR"); + } + + const rolePermissions = response.permissions; + + const newPermissions = permissions.filter( + (permission) => !rolePermissions.includes(permission) + ); + + const removedPermissions = rolePermissions.filter( + (permission) => !permissions.includes(permission) + ); + + await UserRoles.removePermissionsFromRole(role, removedPermissions); + await UserRoles.createNewRoleOrAddPermissions(role, newPermissions); + + return this.getPermissionsForRole(role); + }; +} + +export default RoleService; diff --git a/packages/user/src/model/users/controller.ts b/packages/user/src/model/users/controller.ts index 23709f286..76e18a23b 100644 --- a/packages/user/src/model/users/controller.ts +++ b/packages/user/src/model/users/controller.ts @@ -1,5 +1,8 @@ import handlers from "./handlers"; import { + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, ROUTE_CHANGE_PASSWORD, ROUTE_SIGNUP_ADMIN, ROUTE_ME, @@ -20,7 +23,10 @@ const plugin = async ( fastify.get( ROUTE_USERS, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_USERS_LIST), + ], }, handlersConfig?.users || handlers.users ); @@ -52,7 +58,10 @@ const plugin = async ( fastify.put( ROUTE_USERS_DISABLE, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_USERS_DISABLE), + ], }, handlersConfig?.disable || handlers.disable ); @@ -60,7 +69,10 @@ const plugin = async ( fastify.put( ROUTE_USERS_ENABLE, { - preHandler: fastify.verifySession(), + preHandler: [ + fastify.verifySession(), + fastify.hasPermission(PERMISSIONS_USERS_ENABLE), + ], }, handlersConfig?.enable || handlers.enable ); diff --git a/packages/user/src/model/users/handlers/disable.ts b/packages/user/src/model/users/handlers/disable.ts index 20eefc2e8..3457c0751 100644 --- a/packages/user/src/model/users/handlers/disable.ts +++ b/packages/user/src/model/users/handlers/disable.ts @@ -1,7 +1,3 @@ -import { Error as STError } from "supertokens-node/recipe/session"; -import UserRoles from "supertokens-node/recipe/userroles"; - -import { ROLE_ADMIN } from "../../../constants"; import Service from "../service"; import type { FastifyReply } from "fastify"; @@ -19,24 +15,6 @@ const disable = async (request: SessionRequest, reply: FastifyReply) => { }); } - const roles = await request.session.getClaimValue(UserRoles.UserRoleClaim); - - if (roles === undefined || !roles.includes(ROLE_ADMIN)) { - throw new STError({ - type: "INVALID_CLAIMS", - message: "User is not an admin", - payload: [ - { - id: UserRoles.UserRoleClaim.key, - reason: { - message: "wrong value", - expectedToInclude: ROLE_ADMIN, - }, - }, - ], - }); - } - const service = new Service( request.config, request.slonik, diff --git a/packages/user/src/model/users/handlers/enable.ts b/packages/user/src/model/users/handlers/enable.ts index d6899ff51..dcca9e86d 100644 --- a/packages/user/src/model/users/handlers/enable.ts +++ b/packages/user/src/model/users/handlers/enable.ts @@ -1,7 +1,3 @@ -import { Error as STError } from "supertokens-node/recipe/session"; -import UserRoles from "supertokens-node/recipe/userroles"; - -import { ROLE_ADMIN } from "../../../constants"; import Service from "../service"; import type { FastifyReply } from "fastify"; @@ -11,24 +7,6 @@ const enable = async (request: SessionRequest, reply: FastifyReply) => { if (request.session) { const { id } = request.params as { id: string }; - const roles = await request.session.getClaimValue(UserRoles.UserRoleClaim); - - if (roles === undefined || !roles.includes(ROLE_ADMIN)) { - throw new STError({ - type: "INVALID_CLAIMS", - message: "User is not an admin", - payload: [ - { - id: UserRoles.UserRoleClaim.key, - reason: { - message: "wrong value", - expectedToInclude: ROLE_ADMIN, - }, - }, - ], - }); - } - const service = new Service( request.config, request.slonik, diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index e22709529..7f75d560b 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -1,6 +1,7 @@ import FastifyPlugin from "fastify-plugin"; import mercuriusAuthPlugin from "./mercurius-auth/plugin"; +import hasPermission from "./middlewares/hasPermission"; import supertokensPlugin from "./supertokens"; import userContext from "./userContext"; @@ -17,6 +18,8 @@ const plugin = FastifyPlugin( await fastify.register(supertokensPlugin); + fastify.decorate("hasPermission", hasPermission); + if (mercurius.enabled) { await fastify.register(mercuriusAuthPlugin); }