Building Your Own E-Commerce Keystone.js-Based System — Access Control
Improving upon our e-commerce based system
Introduction
It’s time to continue our series on creating a Keystone.js-based e-commerce system. Previously, we focused on creating all basic models and environment setups. This time we will work on access control and users privileges. Let’s be honest, not all users should be allowed to access all data and have the ability to modify it. It is a serious security risk, so this time we are going to fix it. Also, the finished code for this article is available on my GitHub.
Requirements
So, what are our requirements here? First, we need to restrict users' access based on their role in the system. Basically, customers can only modify data associated with them, but admins have much more privileges. All of this should affect the system on three levels: first, restrict access to Admin UI, then filter access based on operation type to each schema, and lastly on the level of some fields in schemas.
Access Control Implementation
First, let’s update user roles in our system. In the previous article, we started with two of them — admin
and customer
. The basic assumption was that we need two different levels of security with user privileges, but after some consideration, I decided we need another one. So it’s time to update our roles.enum.ts
and roles.const.ts
and add the employee
role. For now, privileges for admin
and employee
will stay the same, but with further system development it may change, as you can see below:
With that out of the way, we can focus on Admin UI access. For sure, all admins and employees should be able to use it; it’s necessary. But customers should not be able to access it. Of course, they need to log into the system and create sessions, but the Admin UI contains sensitive information that mustn’t be widely accessible.
Out of the box, Keystone offers the perfect solution for this kind of problem. To restrict certain users from accessing Admin UI, we just have to update our main config file, keystone.ts
and ui.isAccessAllowed
option. Its function with the context
parameter will return a boolean value. Previously, we were only checking if users had valid session data. Now we have to check if the user has a valid session and if their role is not equal to customer
.
And that’s all; only users with the right role will be able to access Admin UI. Now, it’s time to focus on access control at the level of each schema. Basically, there are two types of restrictions we are going to use: first, everyone can query but only internal users (admin
and employee
) can modify it. The second case covers a scenario when internal users have full access, but customer
user can only query and update dates that belong to that user.
Let’s walk through all the schemas and update the access
property on each of them. First, the Address
list: it contains sensitive data so only admins and owners of specific records should be able to access it.
In Keystone.js’s access API, there are three levels of control (read more), operation
, filter
, and item
. First one, operation
allows restricting what kind of operation can be done on the list (query
, create
, update
, and delete
). Second, filter
allows adding specific GraphQL filters to every operation performed on this list with distinction to its type (same as before, but without create
). Last one, item
works more on the data level and allows us to inspect data passed to each operation, but it works only on mutations, so has no effect on queries.
All properties in this section of the list configuration are functions. The first and last should return boolean; the second one should return GraphQL filter.
In case of Address
list on the operation
level, we only need to check if the user is authenticated, but on the filter
level, we have to check if the user is admin
or employee
. Then there’s no necessity for an additional filter.
But when the user’s role is customer
, we have to create and add filters limiting results only to this user. These functions will be rather similar for each schema, so I’ve decided to move them to another file to prevent unnecessary repetition. I’ve created a shared
folder and index.ts
inside. Also, I’ve created a file for our new method called filter-customer-access.ts
and added it into exports in index.ts
.
Basically, it creates and returns new GraphQL filter checking if the user ID in the record we are trying to access is equal to the ID stored in the current session. Additionally, I’ve added an interface to describe the session parameter, mostly because, in core Keystone files, a session has a type any
that is not so convenient.
We are going to use this method to add this filter to query
, update
, and delete
operations, but not create
. First, it’s not possible, and it needs a different approach. We are going to uralitize item
level restrictions in this case. The function used here will also be reused, so I’ve created another file in the shared
folder called filter-customer-access-create.ts
.
It looks pretty similar to the previous one, but there are two main differences: first, it returns a boolean value, not filter
, and secondly, it checks if the data passed to create mutation has information about the user and if it’s the same user to whom current session belongs. Also, I’ve added export for this method in index.ts
. Now it’s time to add it to the Address
schema config file and its access
section, as you can see below:
Order
, Payment
, Shipment
has the exact same restrictions, so I’ve updated their schemas too. Additionally, in the Order
schema, I’ve updated user
and employee
fields. Previously, they had an option to create a new entity from inside the list which is not really desired here. So I’ve added to this fields’ config:
ui: {
hideCreate: true,
},
``
Cart
schema restrictions are pretty similar with some exceptions. First, there’s no check on the item
level. Instead of that, I’ve added another config option — graphql
. It contains options directly applied to GraphQL schema and allows disabling certain operations, create
in our case.
Because of that, there’s no way to create new Cart
entity via GraphQL API, and it’s something that we want. Cart for user can be created only once, on sign up process. More about that later. I’ve also updated some fields to block the possibility to create a new user and new products from inside this list. The last change was to add the default value to sum
field. Here’s the whole updated schema:
The next group of lists has much simpler restrictions: all users can query them (not authenticated too), but only internal users can create/modify them. This group contains Catgegory
, ProductImage
, Product
, and Stock
. In order to apply these restrictions, we have to add access rules on the operation
level:
In the Stock
schema, I’ve also added an additional access rule on field level, amountInNextDelivery
to be exact. Information about stock delivery may not be strictly confidential or sensitive, but competition is watching, so better keep it for authenticated users.
amountInNextDelivery: integer({
access: { read: ({ session }) => !!session },
}),
The last updated schema is User
; most changes are pretty similar to the Address
list, but there are some exceptions. First, on the item
level, we are not restricting the create
operation. If we do, no one will be able to register.
Instead, I’ve added the same restriction to the update
operation. Additionally, I’ve utilized hooks
here, precisely the afterOperation
one. We will talk more about hooks in further parts of this series, but for now, all we have to know is that they allow you to perform side effects in reference to operations performed on the current list.
Our hook first checks the type of current operation; if it was not the create
operation, it returns undefined
. But in the case of create
, we want to proceed and create a corresponding Cart
entity for that new user. But there’s a catch, previously when updating Cart
schema I’ve disabled GraphQL API responsible for new cart creation, so we have to use prisma
API (more about that). It allows us to skip this restriction without the risk of allowing users to create carts independently. Here’s the updated User
schema:
There’s one thing left to do for this part. We need some test data — users in this case. Prisma.js included in Keystone has a nice way to import initial data (more), so let’s use this feature. Start with installing the ts-node
package as devDependency
:
yarn add -D ts-node
In the meantime, create the folder, seed-data
, and two files inside: data.json
and index.ts
. The first one will be our source of test data; the second will import that data. But additionally, we have to make small changes to our tsconfig.josn
in order to be able to directly import data from the JSON file. So, under compiler options, add:
“resolveJsonModule”: true
Now, let’s add our test data:
The password used here is 1234ABcd@@
for each user. Next, we have to prepare import logic, so let's update index.ts
:
There are two main parts to this importer: first, we are creating a main
method responsible for importing data into the database, and the second part that calls this function in a safe way with proper error catching and the ability to disconnect the database after the job is done.
The basic importer logic is pretty simple; it loads user data from imported JSON then loops over it. For each iteration of this loop, it checks if that user exists in the database; if not it creates it. With that done, the last thing we need here is a way to start this import. So, let’s update our package.json
and add:
"prisma": {
"seed": "ts-node seed-data"
},
Additionally, we need to create a script to start all that using yarn
, so under the scripts add:
"seed": "keystone prisma db seed"
With that done, we are almost at the finish line of this part. We just need to create migrations for the updates we’ve done and import test data. So, let’s first start our database with the following command:
docker-compose -f docker-compose.dev.yml up database
Then, start our backend app outside the container. So in the backend
folder run:
yarn dev
The script will detect changes in the database schema and create a migration for us. We only have to specify a name for it. For example, new_user_role_and_cart_default_values
. After that we can stop this script and seed our test data:
yarn seed
With that done, we can restart the whole system and test it out with this command:
docker-compose -f docker-compose.dev.yml up
Summary
That’s all for this part. With that change, data security in our system improved much, and now we can safely proceed to the next tasks and work on the core elements of every e-commerce system — cart business logic.
I hope you liked it. If you have any questions or comments, feel free to ask them.
Have a nice day!