Skip to content

Commit

Permalink
feat: add role based access control (#564)
Browse files Browse the repository at this point in the history
  • Loading branch information
rameshlohala committed Jan 4, 2024
1 parent 95bb1f9 commit eca8909
Show file tree
Hide file tree
Showing 25 changed files with 719 additions and 95 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
28 changes: 28 additions & 0 deletions packages/user/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion packages/user/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +59,7 @@ declare module "@dzangolab/fastify-config" {
};
};
password?: StrongPasswordOptions;
permissions?: string[];
supertokens: SupertokensConfig;
table?: {
name?: string;
Expand Down Expand Up @@ -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";
Expand All @@ -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";

Expand Down
49 changes: 49 additions & 0 deletions packages/user/src/lib/hasUserPermission.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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;
50 changes: 50 additions & 0 deletions packages/user/src/mercurius-auth/authPlugin.ts
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 53 additions & 0 deletions packages/user/src/mercurius-auth/hasPermissionPlugin.ts
Original file line number Diff line number Diff line change
@@ -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;
47 changes: 5 additions & 42 deletions packages/user/src/mercurius-auth/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,16 @@
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";

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);
}
});

Expand Down
38 changes: 38 additions & 0 deletions packages/user/src/middlewares/hasPermission.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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;

0 comments on commit eca8909

Please sign in to comment.