DEV Community

Cover image for Create a Marketplace with Medusa Part 3: Implement User Management and Permissions
Shahed Nasser for Medusa

Posted on • Updated on • Originally published at medusajs.com

Create a Marketplace with Medusa Part 3: Implement User Management and Permissions

🚨 The content of this tutorial may be outdated. You can instead check out the full code for the series in this GitHub Repository.🚨

In the previous parts of this series, you learned how to build a marketplace using Medusa and Medusa Extender. You associated users, products, and orders to a store.

This part of the tutorial focuses on user management within a store. This entails adding team members to a store, restricting the visibility of users in the whole marketplace based on the store they’re in, and adding access control to manage permissions within a store’s team.

You can find the code for this tutorial in this GitHub repository.

You can alternatively use the Medusa Marketplace plugin as indicated in the README of the GitHub repository. If you’re already using it make sure to update to the latest version:

npm install medusa-marketplace@latest
Enter fullscreen mode Exit fullscreen mode

Prerequisites

It is assumed that you’ve followed along with the first parts of the series before continuing this part. If you haven’t, you should start from the first part of the series.

Alternatively, clone the [part-2 branch of the GitHub repository](https://github.com/shahednasser/medusa-marketplace-tutorial/tree/part-2) and set up and configure your PostgreSQL database. Then, follow the steps in the README.

Medusa Admin

If you don’t have the Medusa Admin installed, it is recommended that you install it so that you can easily view products and orders, among other functionalities.

Alternatively, you can use Medusa’s Admin APIs to access the data on your server. However, the rest of the tutorial will mostly showcase features through the Medusa Admin.

Update Dependencies

Medusa and Medusa Extender both had new releases since the last article. So, before you implement the new functionalities it’s important to update the dependencies related to these 2 first.

In the root of your Medusa server, run the following command to update dependencies:

npm i @medusajs/medusa@latest medusa-extender@latest @medusajs/medusa-cli@latest medusa-interfaces@latest
Enter fullscreen mode Exit fullscreen mode

This will install version 1.3.1 of Medusa’s core packages, and version 1.7.2 of the Medusa Extender.

Please note that you might receive an error if the version of awilix is set to anything other than 4.2.3 while using version 1.3.1. If you get the error, please update the dependency in package.json:

"awilix": "4.2.3"
Enter fullscreen mode Exit fullscreen mode

If at the time you’re following along with the tutorial the versions have changed, please note that there might be some difference between the versions used here and the version you have.

Version 1.7.2 of Medusa Extender changes how migrations are found in the code base and now requires you to include the path in the configuration. So, in medusa-config.js, add the following in the exported object:

module.exports = {
    //other options...
    projectConfig: {
        //other options...
        cli_migration_dirs: [
            'dist/**/*.migration.js'
        ]
    },
};
Enter fullscreen mode Exit fullscreen mode

Changes Based on Update

The Medusa Extender’s newest updates bring changes for a better developer experience as well as new features. There is one change that affects the current code you have.

Change LoggedInUser Middleware

The change entails the loggedInUser middleware located in src/modules/user/middlewares/loggedInUser.middleware.ts. Currently, the middleware runs for all route paths.

The new Medusa Extender update allows you to specify a regular expression pattern for the path route that the current route will be tested on.

So, change the Middleware decorator in src/modules/user/middlewares/loggedInUser.middleware.ts to the following:

@Middleware({ requireAuth: true, routes: [{ method: "all", path: '/admin/*' }] })
Enter fullscreen mode Exit fullscreen mode

Following this change, the middleware now will only run when the route starts with /admin/.

Then, change the code inside the consume method to the following:

public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
    const userService = req.scope.resolve('userService') as UserService;
    const loggedInUser = await userService.retrieve(req.user.userId, {
        select: ['id', 'store_id']
    });
    req.scope.register({
        loggedInUser: {
            resolve: () => loggedInUser,
        },
    });
    next();
}
Enter fullscreen mode Exit fullscreen mode

Previously, you had to check whether the current route is an admin route inside the middleware. As this is not necessary anymore, you just set the logged-in user right away without checking.

Change loggedInUser in Services

This change also means that the loggedInUser will not always be in the scope as you previously implemented it. So, you’ll need to update how the loggedInUser was accessed before in multiple places.

In src/modules/order/order.service.ts, change the loggedInUser in InjectedDependencies to the following:

loggedInUser?: User;
Enter fullscreen mode Exit fullscreen mode

Then, change the if condition in the buildQuery_ method to the following:

if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
    selector['store_id'] = this.container.loggedInUser.store_id;
}
Enter fullscreen mode Exit fullscreen mode

As the container acts as a proxy object to the container object the awilix package gives us, you can check if a property exists in the container using Object.keys(this.container).includes. If you access the property directly and it doesn’t exist in the container, the error AwilixResolutionError will be thrown.

The next change is in src/modules/product/services/product.service.ts. Change the loggedInUser in ConstructorParams to the following:

loggedInUser?: User;
Enter fullscreen mode Exit fullscreen mode

Then, in the prepareListQuery_ method change the declaration and initialization of loggedInUser to the following:

const loggedInUser = Object.keys(this.container).includes('loggedInUser') ? this.container.loggedInUser : null
Enter fullscreen mode Exit fullscreen mode

You’re not making changes in attachStoreToProduct since a product can only be added if a user is logged in.

Finally, in src/modules/store/services/store.service.ts, change the loggedInUser in ConstructorParams to the following:

loggedInUser?: User;
Enter fullscreen mode Exit fullscreen mode

Then, in the retrieve method change the if condition at the beginning of the method to the following:

if (!Object.keys(this.container).includes('loggedInUser')) {
    return super.retrieve(relations);
}
Enter fullscreen mode Exit fullscreen mode

Test Current Code

The new update and changes will not affect how your marketplace was previously functioning. To test it out, run the server:

npm start
Enter fullscreen mode Exit fullscreen mode

Then, you can try out the previous functionalities you implemented. Everything should work as expected.

You can run the Medusa admin to test out functionalities on it by going to the directory of the Medusa admin and running the following command:

npm start
Enter fullscreen mode Exit fullscreen mode

Retrieve Users By Store

Go to your Medusa admin and log in with a user that has a store. Alternatively, you can use the Authenticate a User endpoint.

Then, on the Medusa admin go to Settings > Users, or use the Retrieve all Users REST API. At the moment, only one user is part of each store. However, you can see that all users are retrieved and not just the users that are part of the store the currently logged-in user is in.

Users

To change this, go to src/modules/user/services/user.service.ts and add loggedInUser to ConstructorParams:

type ConstructorParams = {
    //...
    loggedInUser?: User;
};
Enter fullscreen mode Exit fullscreen mode

Next, change the Service decorator to the following:

@Service({ scope: 'SCOPED', override: MedusaUserService })
Enter fullscreen mode Exit fullscreen mode

This allows the UserService to access the loggedInUser in the scope.

Finally, add the following method inside the UserService class:

buildQuery_(selector, config = {}): object {
    if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
        selector['store_id'] = this.container.loggedInUser.store_id;
    }

    return super.buildQuery_(selector, config);
}
Enter fullscreen mode Exit fullscreen mode

This filters out the users based on the store_id of the logged-in user.

Test Retrieve Users by Store

If you restart the Medusa server now and check the Users page, you’ll see only one user in the team now.

Test Users

Add Users to a Store

In the previous implementation, you created a new store for every new user. In this section, you’ll change that to allow users to add other users to their store.

To do that, go to src/modules/store/services/store.service.ts and change the createStoreForNewUser method to the following:

@OnMedusaEntityEvent.Before.Insert(User, { async: true })
public async createStoreForNewUser(
    params: MedusaEventHandlerParams<User, 'Insert'>
): Promise<EntityEventType<User, 'Insert'>> {
    const { event } = params;
    let store_id = Object.keys(this.container).includes("loggedInUser")
      ? this.container.loggedInUser.store_id
      : null;
    if (!store_id) {
        const createdStore = await this.withTransaction(event.manager).createForUser(event.entity);
        if (!!createdStore) {
            store_id = createdStore.id;
        }
    }

    event.entity.store_id = store_id;

    return event;
}
Enter fullscreen mode Exit fullscreen mode

This method now checks if the currently logged-in user has a store_id and attaches it to the new user. Otherwise, it creates a new store for the new user.

This implementation also allows a super admin that does not belong to any store to create new users with new stores.

Test Adding Users to a Store

The Medusa admin only includes the invite functionality to add new users, which you’ll work on in the next section. So, to test out this functionality you need to use the REST APIs.

Restart your Medusa server, then log in using the REST API with a user that belongs to a store.

Next, send a request to the Create a User endpoint.

Create User Request

You can now go to the Medusa admin or use the Retrieve a User endpoint. You should see one more user in the team other than the previously created user.

Check added user

Associate Invites with Stores

When an admin user invites another user to join their team, whether through an endpoint or through the Medusa admin, a new invite is created.

Then, when the invite is accepted, a user is created using information from that invite as well as information that the person enters when they accept the invite.

In this section, you’ll make the necessary changes that associate an invite with a store and ensure that when the invite is accepted the created user is also associated with the store.

Create Invite Entity

Create the directory src/modules/invite. This will hold all files related to the invites module.

Then, create the file src/modules/invite/invite.entity.ts with the following content:

import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";

import { Entity as MedusaEntity } from "medusa-extender";
import { Invite as MedusaInvite } from "@medusajs/medusa";
import { Store } from "../store/entities/store.entity";

@MedusaEntity({override: MedusaInvite})
@Entity()
export class Invite extends MedusaInvite {
    @Index()
    @Column({ nullable: true })
    store_id: string;

    @ManyToOne(() => Store, (store) => store.invites)
    @JoinColumn({ name: 'store_id' })
    store: Store;
}
Enter fullscreen mode Exit fullscreen mode

You extend the Invite entity from Medusa’s core and add to it the store_id field and the store relation.

This relation should also be added to the Store entity.

So, in src/modules/store/entities/store.entity.ts add the following import at the beginning of the file:

import { Invite } from './../../invite/invite.entity';
Enter fullscreen mode Exit fullscreen mode

And add the following inside the Store class:

@OneToMany(() => Invite, (invite) => invite.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
invites: Invite[];
Enter fullscreen mode Exit fullscreen mode

Create Invite Repository

Next, create the file src/modules/invite/invite.repository.ts with the following content:

import { Repository as MedusaRepository, Utils } from "medusa-extender";

import { EntityRepository } from "typeorm";
import { Invite } from "./invite.entity";
import { InviteRepository as MedusaInviteRepository } from "@medusajs/medusa/dist/repositories/invite";

@MedusaRepository({override: MedusaInviteRepository})
@EntityRepository(Invite)
export class InviteRepository extends Utils.repositoryMixin<Invite, MedusaInviteRepository>(MedusaInviteRepository) {}
Enter fullscreen mode Exit fullscreen mode

This creates a repository for the Invite entity that extends InviteRepository from Medusa’s core. This allows you to retrieve invites from the database based on the Invite entity you created in the previous section.

Create Migration

To reflect the new column in the database, you need to create a migration file in the invite module.

As migration files have the format <timestamp>-invite.migration.ts, a migration file is unique to you so you need to create it yourself.

You can generate the migration file using the following command provided by Medusa Extender’s CLI:

./node_modules/.bin/medex g -mi invite

Enter fullscreen mode Exit fullscreen mode

This creates the file src/modules/invite/<timestamp>-invite.migration.ts for you. Open that file and replace the up and down methods with the following implementation:

public async up(queryRunner: QueryRunner): Promise<void> {
    const query = `
        ALTER TABLE public."invite" ADD COLUMN IF NOT EXISTS "store_id" text; 
    `;
    await queryRunner.query(query);
}

public async down(queryRunner: QueryRunner): Promise<void> {
    const query = `
        ALTER TABLE public."invite" DROP COLUMN "store_id";
    `;
    await queryRunner.query(query);
}
Enter fullscreen mode Exit fullscreen mode

The up method adds the store_id column to the invite table, and the down method drops the column.

Run Migrations

To actually reflect these changes in the database, you need to run the migrations.

As migrations can only be run as JavaScript files, run the build command to transpile the TypeScript files to JavaScript files:

npm run build
Enter fullscreen mode Exit fullscreen mode

Then, use the migrate command provided by the Medusa Extender CLI to run those migrations:

./node_modules/.bin/medex migrate --run
Enter fullscreen mode Exit fullscreen mode

If you get an error about duplicate migrations because of migrations from the previous part of this series, go ahead and remove the old ones from the dist directory and try running the command again.

If you check your database after the migration is run successfully, you can see that the new column store_id added to the invite table.

Change List Invites

Similar to the Retrieve all Users functionality, the List all Invites functionality currently returns all invites irrespective of which store the invite belongs to.

If you try to invite a user to a store using the Medusa admin or using the REST APIs, then log into another store with another user, you can see the invite there as well. Only invites that belong to the store of the currently logged-in user should be shown.

Invites

So, in this section, you’ll implement the necessary changes so that only invites specific to a store are retrieved.

Create the file src/modules/invite/invite.service.ts with the following content:

import { ConfigModule } from '@medusajs/medusa/dist/types/global';
import { EntityManager } from 'typeorm';
import { EventBusService } from '@medusajs/medusa';
import { Invite } from './invite.entity';
import { InviteRepository } from './invite.repository';
import { default as MedusaInviteService } from "@medusajs/medusa/dist/services/invite";
import { Service } from "medusa-extender";
import { User } from '../user/entities/user.entity';
import UserRepository from '../user/repositories/user.repository';
import UserService from '../user/services/user.service';

type InviteServiceProps = {
  manager: EntityManager;
  userService: UserService;
  userRepository: UserRepository;
  eventBusService: EventBusService;
  loggedInUser?: User;
  inviteRepository: InviteRepository;
}

@Service({ scope: 'SCOPED', override: MedusaInviteService })
export class InviteService extends MedusaInviteService {
    static readonly resolutionKey = "inviteService"

  private readonly manager: EntityManager;
  private readonly container: InviteServiceProps;
  private readonly inviteRepository: InviteRepository;

  constructor(container: InviteServiceProps, configModule: ConfigModule) {
    super(container, configModule);

    this.manager = container.manager;
    this.container = container;
    this.inviteRepository = container.inviteRepository
  }

    withTransaction(transactionManager: EntityManager): InviteService {
    if (!transactionManager) {
      return this
    }

    const cloned = new InviteService({
      ...this.container,
      manager: transactionManager
    },
    this.configModule_
    )

    cloned.transactionManager = transactionManager

    return cloned
  }

  buildQuery_(selector, config = {}): object {
    if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
        selector['store_id'] = this.container.loggedInUser.store_id;
    }

    return super.buildQuery_(selector, config);
  }
}
Enter fullscreen mode Exit fullscreen mode

You override the InviteService from Medusa’s core. Inside the service, you override the buildQuery_ method to filter the invites based on the store ID of the currently logged-in user.

Create Invite Module

Before you can test out what you just implemented, you need to create an invite module.

Create the file src/modules/invite/invite.module.ts with the following content:

import { Invite } from "./invite.entity";
import { InviteMigration1655123458263 } from './1655123458263-invite.migration';
import { InviteRepository } from './invite.repository';
import { InviteService } from './invite.service';
import { Module } from "medusa-extender";

@Module({
  imports: [
    Invite,
    InviteRepository,
    InviteService,
    InviteMigration1655123458263,
  ]
})
export class InviteModule {}
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the migration with your own migration class name and file path.

Then, in src/main.ts import the InviteModule at the beginning of the file:

import { InviteModule } from './modules/invite/invite.module';
Enter fullscreen mode Exit fullscreen mode

and add it to the array passed to the load function:

await new Medusa(__dirname + '/../', expressInstance).load([
    //...
    InviteModule,
]);
Enter fullscreen mode Exit fullscreen mode

Listen to Create Invite

In this section, you’ll listen to the “before insert” event on invites then attach store_id of the logged-in user to the invite.

To listen to events on entities such as the “before insert” event, you need to create a subscriber and a middleware that registers the subscriber.

To create the subscriber, create the file src/modules/invite/invite.subscriber.ts with the following content:

import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
import { Utils as MedusaUtils, OnMedusaEntityEvent, eventEmitter } from 'medusa-extender';

import { Invite } from './invite.entity';

@EventSubscriber()
export default class InviteSubscriber implements EntitySubscriberInterface<Invite> {
    static attachTo(connection: Connection): void {
        MedusaUtils.attachOrReplaceEntitySubscriber(connection, InviteSubscriber);
    }

    public listenTo(): typeof Invite {
        return Invite;
    }

    /**
     * Relay the event to the handlers.
     * @param event Event to pass to the event handler
     */
    public async beforeInsert(event: InsertEvent<Invite>): Promise<void> {
        return await eventEmitter.emitAsync(OnMedusaEntityEvent.Before.InsertEvent(Invite), {
            event,
            transactionalEntityManager: event.manager,
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

This subscriber emits the “before insert” event whenever a new invite is created.

Then, create the file src/modules/invite/inviteSubscriber.middleware.ts with the following content:

import {
  MEDUSA_RESOLVER_KEYS,
  MedusaAuthenticatedRequest,
  MedusaMiddleware,
  Utils as MedusaUtils,
  Middleware
} from 'medusa-extender';
import { NextFunction, Response } from 'express';

import { Connection } from 'typeorm';
import InviteSubscriber from './invite.subscriber';

@Middleware({ requireAuth: true, routes: [{ method: "post", path: '/admin/invites*' }] })
export class AttachInviteSubscriberMiddleware implements MedusaMiddleware {
    public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
      const { connection } = req.scope.resolve(MEDUSA_RESOLVER_KEYS.manager) as { connection: Connection };
      InviteSubscriber.attachTo(connection)
      return next();
    }
}
Enter fullscreen mode Exit fullscreen mode

This middleware registers the subscriber on all routes that begin with /admin/invites if the request method is POST.

Next, in src/modules/store/services/store.service.ts import Invite at the beginning of the file:

import { Invite } from '../../invite/invite.entity';
Enter fullscreen mode Exit fullscreen mode

Then, add the following method to listen to the “before insert” event and handle it:

@OnMedusaEntityEvent.Before.Insert(Invite, { async: true })
public async addStoreToInvite(
    params: MedusaEventHandlerParams<Invite, 'Insert'>
): Promise<EntityEventType<Invite, 'Insert'>> {
    const { event } = params; //invite to be created is in event.entity
    let store_id = this.container.loggedInUser.store_id

    if (!event.entity.store_id && store_id) {
        event.entity.store_id = store_id;
    }

    return event;
}
Enter fullscreen mode Exit fullscreen mode

This checks if the invite doesn’t already have a store_id and if the current user has a store_id, then set the store_id of the invite to the logged-in user’s invite.

This implementation also enables the super admin to send invites to other super admins.

Finally, you need to add the middleware to the invite module before you can test it out.

In src/modules/invite/invite.module.ts add the following import at the beginning of the file:

import { AttachInviteSubscriberMiddleware } from "./inviteSubscriber.middleware";
Enter fullscreen mode Exit fullscreen mode

Then, add the AttachInviteSubscriberMiddleware class to the imports array passed to the Module directive:

@Module({
  imports: [
        //...
    AttachInviteSubscriberMiddleware,
  ]
})
Enter fullscreen mode Exit fullscreen mode

Test List and Create Invites

Restart the server and check the Users page again. Then, create an invite. The invite should now appear in the store it’s associated with and not in all stores.

Invite per store

Associate Invited User with Store

When the invite is accepted, a new user is created. However, the current implementation does not associate the new user with any store.

In this section, you’ll override the Accept an Invite endpoint to add this implementation.

Go to src/modules/user/services/user.service.ts and add the new method addUserToStore:

public async addUserToStore (user_id, store_id) {
await this.atomicPhase(async (m) => {
      const userRepo = m.getCustomRepository(this.userRepository);
      const query = this.buildQuery_({ id: user_id });

      const user = await userRepo.findOne(query);
      if (user) {
          user.store_id = store_id;
          await userRepo.save(user);
      }
  })
}
Enter fullscreen mode Exit fullscreen mode

Also, add the withTransaction method to override the parent’s method and make sure it returns the custom user service:

withTransaction(transactionManager: EntityManager): UserService {
    if (!transactionManager) {
        return this
    }

    const cloned = new UserService({
        ...this.container,
        manager: transactionManager
    })

    cloned.transactionManager = transactionManager

    return cloned
}
Enter fullscreen mode Exit fullscreen mode

Next, you need to add a new method to InviteService that retrieves an invite by ID.

In src/modules/invite/invite.service.ts add the following method in InviteService:

async retrieve (invite_id: string) : Promise<Invite|null> {
    return await this.atomicPhase_(async (m) => {
    const inviteRepo: InviteRepository = m.getCustomRepository(
      this.inviteRepository
    )

    return await inviteRepo.findOne({ where: { id: invite_id } })

  })
}
Enter fullscreen mode Exit fullscreen mode

You can now override the original route that handles accepting invites.

Create the file src/modules/invite/acceptInvite.controller.ts with the following content:

import { AdminPostInvitesInviteAcceptReq } from "@medusajs/medusa"
import { InviteService } from './invite.service';
import { MedusaError } from 'medusa-core-utils';
import UserService from '../user/services/user.service';
import { validator } from "@medusajs/medusa/dist/utils/validator"

export default async (req, res) => {
    const validated = await validator(AdminPostInvitesInviteAcceptReq, req.body)

  const inviteService: InviteService = req.scope.resolve(InviteService.resolutionKey)

  const manager: EntityManager = req.scope.resolve("manager")

  await manager.transaction(async (m) => {
    //retrieve invite
    let decoded
    try {
      decoded = inviteService
      .withTransaction(m)
      .verifyToken(validated.token)
    } catch (err) {
      throw new MedusaError(
        MedusaError.Types.INVALID_DATA,
        "Token is not valid"
      )
    }

    const invite = await inviteService
        .withTransaction(m)
        .retrieve(decoded.invite_id);

    let store_id = invite ? invite.store_id : null;

    const user = await inviteService
    .withTransaction(m)
    .accept(validated.token, validated.user);

    if (store_id) {
      const userService: UserService = req.scope.resolve("userService");
      await userService
            .withTransaction(m)
            .addUserToStore(user.id, store_id);
    }

    res.sendStatus(200)
  })await userService
      .withTransaction(m)
      .addUserToStore(user.id, store_id);
    }

    res.sendStatus(200)
  })
}
Enter fullscreen mode Exit fullscreen mode

This uses the same implementation as the original accept invite endpoint. However, it retrieves the invite using the retrieve method you created earlier. Then, it retrieves the store_id of the invite.

The accept method in inviteService creates a new user and returns it. You then check if the store_id was set on the invite and add the store_id to the user using the userService.addUserToStore method.

To make this function the handler of the Accept an Invite endpoint, create the file src/modules/invite/invite.router.ts with the following content:

import { Router } from 'medusa-extender';
import acceptInvite from './acceptInvite.controller';

@Router({
    routes: [
        {
            requiredAuth: false,
            path: '/admin/invites/accept',
            method: 'post',
            handlers: [acceptInvite],
        },
    ],
})
export class AcceptInviteRouter {}
Enter fullscreen mode Exit fullscreen mode

The last step is to add this router to the invite module.

In src/modules/invite/invite.module.ts import this class at the beginning of the function:

import { AcceptInviteRouter } from "./invite.router";
Enter fullscreen mode Exit fullscreen mode

Then, add it to the imports array passed to the Module directive:

@Module({
  imports: [
    //...
    AcceptInviteRouter,
  ]
})
Enter fullscreen mode Exit fullscreen mode

Test Accepting Invites

Restart your server. Then, send a request to the List all Invites endpoint and retrieve the token of the invite you want to accept.

After you retrieve the token, send a request to the Accept an Invite endpoint. You can then check if the new user shows up in the store it’s associated with it.

User accepted invite

Implementing Roles and Permissions

This section covers how to implement user roles and permissions in a brief way. You may choose to implement it differently based on your use case.

This entails creating two new entities Role and Permission. A permission can be associated with multiple roles, and a role can have multiple permissions. Additionally, a role is associated with a store, and a user is associated with a role.

Then, you’ll create a guard middleware that you can add to any route you want to protect based on a certain permission. You’ll see an example of how to use the guard middleware.

⚠️ This section does not cover the endpoints necessary to add permissions and roles and associate them with users. You’ll need to implement it yourself.

Create the Role Module

Start by creating the src/modules/role directory that holds all files related to the role module.

Then, create the file src/modules/role/role.entity.ts with the following content:

import { BeforeInsert, Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from "typeorm";

import { BaseEntity } from "@medusajs/medusa";
import { Entity as MedusaEntity } from "medusa-extender";
import { Permission } from '../permission/permission.entity';
import { Store } from "../store/entities/store.entity";
import { User } from "../user/entities/user.entity";
import { generateEntityId } from "@medusajs/medusa/dist/utils";

@MedusaEntity()
@Entity()
export class Role extends BaseEntity {

  @Column({type: "varchar"})
  name: string;

  @Index()
    @Column({ nullable: true })
    store_id: string;

  @ManyToMany(() => Permission)
  @JoinTable({
    name: "role_permissions",
    joinColumn: {
      name: "role_id",
      referencedColumnName: "id",
    },
    inverseJoinColumn: {
      name: "permission_id",
      referencedColumnName: "id",
    },
  })
  permissions: Permission[]

  @OneToMany(() => User, (user) => user.teamRole)
    @JoinColumn({ name: 'id', referencedColumnName: 'role_id' })
    users: User[];

  @ManyToOne(() => Store, (store) => store.roles)
    @JoinColumn({ name: 'store_id' })
    store: Store;

  @BeforeInsert()
  private beforeInsert(): void {
    this.id = generateEntityId(this.id, "role")
  }
}
Enter fullscreen mode Exit fullscreen mode

If you see errors about missing imports or methods, you’ll be resolving them as you proceed with the next steps.

This creates a Role entity that has the properties name and store_id as well as the relations mentioned earlier.

You need to add these relations to the Store and User entities.

Go to src/modules/user/entities/user.entity.ts and add the following import at the beginning of the file:

import { Role } from '../../role/role.entity';
Enter fullscreen mode Exit fullscreen mode

Then, add the following inside the User entity:

@Index()
@Column({ nullable: true })
role_id: string;

@ManyToOne(() => Role, (role) => role.users)
@JoinColumn({ name: 'role_id' })
teamRole: Role;
Enter fullscreen mode Exit fullscreen mode

Next, go to src/modules/store/entities/store.entity.ts and add the following import at the beginning of the file:

import { Role } from '../../role/role.entity';
Enter fullscreen mode Exit fullscreen mode

Then, add the following inside the Store entity:

@OneToMany(() => Role, (role) => role.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
roles: Role[];
Enter fullscreen mode Exit fullscreen mode

Then, create the file src/modules/role/role.repository.ts with the following content:

import { EntityRepository, Repository } from "typeorm";

import { Repository as MedusaRepository } from "medusa-extender";
import { Role } from './role.entity';

@MedusaRepository()
@EntityRepository(Role)
export class RoleRepository extends Repository<Role> {}
Enter fullscreen mode Exit fullscreen mode

Next, you need to create 2 migrations: one to create the role table and one to add the role_id column to the user table.

Run the following command to create the role migration:

./node_modules/.bin/medex g -mi role
Enter fullscreen mode Exit fullscreen mode

Then, go to src/modules/role/<TIMESTAMP>-role.migration.ts and add the following import at the beginning of the file:

import { TableForeignKey } from 'typeorm';
Enter fullscreen mode Exit fullscreen mode

Then, replace the up and down methods with the following:

public async up(queryRunner: QueryRunner): Promise<void> {
    const query = `
    CREATE TABLE "role" ("id" character varying NOT NULL, 
    "name" character varying NOT NULL, "store_id" character varying NOT NULL,
    "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())
    `;
    await queryRunner.query(query);

    await queryRunner.createPrimaryKey("role", ["id"])
    await queryRunner.createForeignKey("role", new TableForeignKey({
        columnNames: ["store_id"],
        referencedColumnNames: ["id"],
        referencedTableName: "store",
        onDelete: "CASCADE",
        onUpdate: "CASCADE"
    }))
}

public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("role", true);
}
Enter fullscreen mode Exit fullscreen mode

Next, run the following command:

./node_modules/.bin/medex g -mi user
Enter fullscreen mode Exit fullscreen mode

Running this command can mess up the file src/modules/user/user.module.ts as the files in the user module are based on an old structure that the Medusa Extender used. Please check the file for any errors in the imports and resolve them. If it adds an import for UserSubscriber you can safely remove it as well as remove it from the imports array passed to Module.

Go to src/modules/user/<TIMESTAMP>-user.migration.ts and add the following import at the beginning of the file:

import { TableForeignKey } from 'typeorm';
Enter fullscreen mode Exit fullscreen mode

Then, replace the up and down methods with the following content:

public async up(queryRunner: QueryRunner): Promise<void> {
    const query = `ALTER TABLE public."user" ADD COLUMN IF NOT EXISTS "role_id" text;`;
    await queryRunner.query(query);

  await queryRunner.createForeignKey("user", new TableForeignKey({
      columnNames: ["role_id"],
      referencedColumnNames: ["id"],
      referencedTableName: "role",
      onDelete: "CASCADE",
      onUpdate: "CASCADE"
  }))
}

public async down(queryRunner: QueryRunner): Promise<void> {
    const query = `ALTER TABLE public."user" DROP COLUMN "role_id";`;
    await queryRunner.query(query);
}
Enter fullscreen mode Exit fullscreen mode

Next, create the file src/modules/role/role.module.ts with the following content:

import { Module } from "medusa-extender";
import { Role } from "./role.entity";
import { RoleMigration1655131148363 } from './1655131148363-role.migration';
import { RoleRepository } from "./role.repository";

@Module({
  imports: [
    Role,
    RoleRepository,
    RoleMigration1655131148363
  ]
})
export class RoleModule {}
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the migration with your own migration class name and file path.

Finally, at the beginning of src/main.ts import the role module:

import { RoleModule } from './modules/role/role.module';
Enter fullscreen mode Exit fullscreen mode

and add it in the array passed to the load function:

await new Medusa(__dirname + '/../', expressInstance).load([
    //...
    RoleModule,
]);
Enter fullscreen mode Exit fullscreen mode

Create the Permission Module

Start by creating the src/modules/permission folder that holds all files related to the permission module.

Then, create the file src/modules/permission/permission.entity.ts with the following content:

import { BeforeInsert, Column, Entity, JoinTable, ManyToMany } from "typeorm";

import { BaseEntity } from "@medusajs/medusa";
import { DbAwareColumn } from "@medusajs/medusa/dist/utils/db-aware-column";
import { Entity as MedusaEntity } from "medusa-extender";
import { Role } from "../role/role.entity";
import { generateEntityId } from "@medusajs/medusa/dist/utils";

@MedusaEntity()
@Entity()
export class Permission extends BaseEntity {

  @Column({type: "varchar"})
  name: string;

  @DbAwareColumn({ type: "jsonb", nullable: true })
  metadata: Record<string, unknown>

  @ManyToMany(() => Role)
  @JoinTable({
    name: "role_permissions",
    joinColumn: {
      name: "permission_id",
      referencedColumnName: "id",
    },
    inverseJoinColumn: {
      name: "role_id",
      referencedColumnName: "id",
    },
  })
  roles: Role[]

  @BeforeInsert()
  private beforeInsert(): void {
    this.id = generateEntityId(this.id, "perm")
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates the entity Permission which has the attributes name and metadata. You can use the metadata attribute, which acts as an object with key-value pairs, to add whatever condition that these permissions entail. For example, you can add the name of paths that the role this permission is associated with can access.

Then, create the file src/modules/permission/permission.repository.ts with the following content:

import { EntityRepository, Repository } from "typeorm";

import { Repository as MedusaRepository } from "medusa-extender";
import { Permission } from './permission.entity';

@MedusaRepository()
@EntityRepository(Permission)
export class PermissionRepository extends Repository<Permission> {}
Enter fullscreen mode Exit fullscreen mode

Next, you need to create the migration for permission. Run the following command to create the migration:

./node_modules/.bin/medex g -mi permission
Enter fullscreen mode Exit fullscreen mode

Then, open the file src/modules/permission/<TIMESTAMP>-permission.migration.ts and add the following import at the beginning of the file:

import { TableForeignKey } from 'typeorm';
Enter fullscreen mode Exit fullscreen mode

and replace the up and down methods with the following:

public async up(queryRunner: QueryRunner): Promise<void> {
    let query = `
    CREATE TABLE "permission" ("id" character varying NOT NULL, 
    "name" character varying NOT NULL, "metadata" jsonb,
    "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())`;
    await queryRunner.query(query);

    await queryRunner.createPrimaryKey("permission", ["id"])

    query = `
    CREATE TABLE "role_permissions" ("role_id" character varying NOT NULL, "permission_id" character varying NOT NULL,
    "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())`;

    await queryRunner.query(query);

    await queryRunner.createPrimaryKey("role_permissions", ["role_id", "permission_id"])

    await queryRunner.createForeignKey("role_permissions", new TableForeignKey({
        columnNames: ["role_id"],
        referencedColumnNames: ["id"],
        referencedTableName: "role",
        onDelete: "CASCADE",
        onUpdate: "CASCADE"
    }))

    await queryRunner.createForeignKey("role_permissions", new TableForeignKey({
        columnNames: ["permission_id"],
        referencedColumnNames: ["id"],
        referencedTableName: "permission",
        onDelete: "CASCADE",
        onUpdate: "CASCADE"
    }))
}

public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("role_permissions", true);
    await queryRunner.dropTable("permission", true);
}
Enter fullscreen mode Exit fullscreen mode

Notice that in this migration you also create the table role_permissions which creates the many-to-many relation between the two entities.

The final step is to add the guard middleware that will handle checking whether the user can access an endpoint or not.

Create the file src/modules/permission/permission.guard.ts with the following content:

import UserService from "../user/services/user.service";
import _ from "lodash";

export default (permissions: Record<string, unknown>[]) => {
  return async (req, res, next) => {
    const userService = req.scope.resolve('userService') as UserService;
    const loggedInUser = await userService.retrieve(req.user.userId, {
        select: ['id', 'store_id'],
        relations: ['teamRole', 'teamRole.permissions']
    });

    const isAllowed = permissions.every(permission => 
      loggedInUser.teamRole?.permissions.some((userPermission) => _.isEqual(userPermission.metadata, permission))
    )

    if (isAllowed) {      
      return next()    
    }

    //permission denied
    res.sendStatus(401)
  }
}
Enter fullscreen mode Exit fullscreen mode

The guard accepts the parameter permissions which is an array of type any. Then, it returns a function that accepts the req, res, and next parameters as every middleware in Express.

The permissions parameter is an array of permissions that the logged in user must have before accessing a route. So, inside the returned middleware function, you check that the logged in user has every item in permissions as part of their role. You use the metadata field in the Permission entity to check for equality between the user’s permissions and the required permissions for this route.

If the user has all permissions, they are admitted to the route by calling next. Otherwise, the 401 unauthorized status is returned.

Next, create the file src/modules/permission/permission.module.ts with the following content:

import { Module } from "medusa-extender";
import { Permission } from "./permission.entity";
import { PermissionMigration1655131601491 } from "./1655131601491-permission.migration";
import { PermissionRepository } from "./permission.repository";

@Module({
  imports: [
    Permission,
    PermissionRepository,
    PermissionMigration1655131601491
  ]
})
export class PermissionModule {} 
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the migration with your own migration class name and file path.

Finally, import this file at the beginning of src/main.ts:

import { PermissionModule } from './modules/permission/permission.module';
Enter fullscreen mode Exit fullscreen mode

And add the class to the array passed to the load function:

await new Medusa(__dirname + '/../', expressInstance).load([
      //...
    PermissionModule,
]);
Enter fullscreen mode Exit fullscreen mode

Run Migrations

Run the build command to transpile the TypeScript files to JavaScript files:

npm run build
Enter fullscreen mode Exit fullscreen mode

If you see an error when you run this command, check the imports in src/modules/invite/invite.module.ts . This is because of how the Medusa Extender CLI works when you ran the migrate command earlier. If you see an import for InviteSubscriber you can safely remove it as well as remove it from the imports array passed to Module.

Then, use the migrate command provided by the Medusa Extender CLI to run those migrations:

./node_modules/.bin/medex migrate --run
Enter fullscreen mode Exit fullscreen mode

If you get an error about duplicate migrations because of previous migrations, go ahead and remove the old ones from the dist directory and try running the command again.

If you check your database after the migration is run successfully, you can see that 2 new tables role and permission have been added to the database, and the user table has a new column role_id.

Test Roles and Permissions

As mentioned earlier in this section, you’ll need to either implement endpoints to add roles and permissions yourself or add them directly to the database if you want to test it out.

Then, to use the permissions middleware, you can pass it to the handlers array of any router. For example, here’s a router that restricts access to the List Products endpoint:

import { Router } from 'medusa-extender';
import listProductsHandler from '@medusajs/medusa/dist/api/routes/admin/products/list-products';
import permissionGuard from '../permission/permission.guard';
import wrapHandler from '@medusajs/medusa/dist/api/middlewares/await-middleware';

@Router({
    routes: [
        {
            requiredAuth: true,
            path: '/admin/products',
            method: 'get',
            handlers: [
              permissionGuard([
                {path: "/admin/products"}
              ]),
              wrapHandler(listProductsHandler)
            ],
        },
    ],
})
export class ProductsRouter {}
Enter fullscreen mode Exit fullscreen mode

Notice that you pass the argument [{path: "/admin/products"}] as the permission to check for. If the user has permission that has metadata with the same value as the argument, they’ll be able to access the endpoint. Otherwise, they’ll be unauthorized to access.

You can pass multiple permissions in the array.

Make sure to pass the router to the module it is associated with and restart the server before testing it out.

Unauthorized request

What’s Next?

In the next tutorial in this series, you’ll learn how to make customization to endpoints and to the Medusa admin to make sure the super admin can manage the marketplace as a whole.

You can also check out the following resources for additional help while developing your marketplace with Medusa:

Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.

Top comments (1)