DEV Community

Christian Engel
Christian Engel

Posted on • Originally published at parastudios.de

Limit access of Strapi users to their own entries

This was actually one of the first problems I encountered when I began using Strapi version 4 as a backend boilerplate for my streaming service for steam broadcasts side project.

What I wanted was that when a user requested a list of or a certain entity, the backend should only respond with entities which belong to the user. By default, Strapi would send all entities from everyone.

Lets say you create a TODO app with a /tasks endpoint. Each user should be able to create tasks by doing POST requests but if they do a GET request to /tasks they should not see everyones tasks. Also PUT requests should only work when the given task belongs to the user.

So how do we achieve this?

tl;dr

Use a service call in the create() method to set the owner field with system privileges.
Force a filter to the id of the current user in all other methods.

Extend the Strapi controller

Lets stay with the tasks example for this article. The moment you create that data type through the Strapi Content-Type Builder, Strapi will create a dummy controller in /src/api/task/controllers/task.js.

The content will look like this:

'use strict';

/**
 *  task controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::task.task');

Enter fullscreen mode Exit fullscreen mode

In order to get the behavior we want to achieve, we need to roll our own find(), findOne(), update() and delete() methods to limit the access. The create() route needs to be defined as well, since - as far as I know - Strapi is unable to link the current user to an entry automatically.

So lets begin with saving the creator of a task in its own field. I added a new field in the Strapi Content-Type Builder which is a relation to the users table. Yes, Strapi will keep track of the user which was the creator of an entry but this is not editable. If you as an administrator create a task for a user in the admin interface, your name will be set as the creator and you can't do anything to change that. So we create a "real field" on the entry.

Strapi relation field dialog

The create() method

Now lets update the task controller so upon creation the owner field will be set to the id of the user which issued the request.

'use strict';

/**
 *  task controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::task.task', {

    async create(ctx) {
        const user = ctx.state.user;

        const task = await super.create(ctx);

        const updated = await strapi.entityService.update("api::task.task", task.data.id, {
            data: {
                owner: user.id
            }
        });

        return result;
    },

});

Enter fullscreen mode Exit fullscreen mode

With this code in place - whenever a user creates a new task via http POST, the controller will read the user object from the request state (a bit like a session), create a task and then immediately update it by setting the owner field to the user id.

Why the two step process?

The immediate thought I would have when reading the above code would be: why not sneaking the owner id into the payload that is passed to super.create() and avoiding the separete call to update()?

Well, the reason is that this will only work when the issuing user has access to modify the users table. If not, there is no way Strapi will let you set that value. So by calling super.create(ctx) first, we make sure the task entry is created in the name of the user.

Our next call to strapi.entityService.update() is done with system privileges, tough. The action is not related to a user so no permissions will be checked. The owner field will be set with super admin privileges (so be extra careful when doing this).

Heads up!

The automatic owner assignment will not work when you create tasks inside the admin interface. Controller methods are only being called when requests are made through the public REST or GraphQL api.

The find() method

When the tasks application issues a GET request to /tasks, Strapi will return a list of all existing tasks, of all users. To prevent that, we need to forcefully add a filter to the query.

async find(ctx){
    const user = ctx.state.user;

    ctx.query.filters = {
        ...(ctx.query.filters || {}),
        owner: user.id
    };

    return super.find(ctx);
}
Enter fullscreen mode Exit fullscreen mode

Again, we get the current user object from the request state. Then we update the filters property of the query object for the request. We copy over all filters which have been passed by the user, then we set the owner field to the current users id. This way, the filter is always set serverside, even if the user would pass in a forged owner parameter from the outside. It would not be possible to request tasks of a different user.

The other methods

Since wrapping the other methods in the same filter is basically copy/paste, I will put them all together, here:

async findOne(ctx){
    const user = ctx.state.user;

    ctx.query.filters = {
        ...(ctx.query.filters || {}),
        owner: user.id
    };

    return super.findOne(ctx);
},
async update(ctx){
    const user = ctx.state.user;

    ctx.query.filters = {
        ...(ctx.query.filters || {}),
        owner: user.id
    };

    return super.update(ctx);
},
async delete(ctx){
    const user = ctx.state.user;

    ctx.query.filters = {
        ...(ctx.query.filters || {}),
        owner: user.id
    };

    return super.delete(ctx);
}
Enter fullscreen mode Exit fullscreen mode

You may want to DRY your code a bit, but I actually copy/pasted the parts for real to be able to make other customizations more quickly.


This post was published on my personal blog, first.

You should follow me on dev.to for more tips like this. Click the follow button in the sidebar! 🚀

Top comments (19)

Collapse
 
uschtwill profile image
Wilhelm Uschtrin

Hey, just made an account to say thank you. Been looking for a working write-up to this use case all day. Also posted your create() method here, I think result is undefined though, right?. Many thanks! ❤️

Collapse
 
paratron profile image
Christian Engel

You are right, the create method should return updated!

Collapse
 
sajjadalis profile image
Sajjad Ali

I ended up doing like this. I wonder if this is the best way to fetch single record if it belongs to user?

async findOne(ctx){
    const user = ctx.state.user;
    const { id } = ctx.params;
    const invoice = await strapi.entityService.findOne('api::invoice.invoice', id, {
      populate: { user_id: { fields: ['id'], } },
    })

    if (invoice?.user_id?.id == user.id) {
      return invoice;
    } else {
      return {
        data: null,
        error: {
            message: 'Not authorized'
        }
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
paratron profile image
Christian Engel

While this certainly works, I would not advise to do it this way. What you do here is fetch the whole invoice object from the database, as well as the user object (partially) and move that data over to the nodeJS context. Then you look into the object and discard it if it does not match the desired user id.

Thats like ordering a car and have it delivered to a store next to you and only checking you are too young to actually buy it just before handing the car over to you.

Why didn't you use the filters parameter on findOne() like in my blog post above? I think this should work fine:

const invoice = await strapi.entityService.findOne('api::invoice.invoice', id, {
      filters: { user_id: { $eq: user.id, } },
    })
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dassiorleando profile image
Orleando Dassi • Edited

As per what I see in the documentation, the findOne method of the entityService doesn't support filters but the findMany does.

I was able to perform a single request using the "Query Engine API" instead, as follows:

const invoice = await strapi.query('api::invoice.invoice')
                            .findOne({ where: { id, user_id: user.id } });
Enter fullscreen mode Exit fullscreen mode

Documentation: docs.strapi.io/dev-docs/api/query-...

Collapse
 
sajjadalis profile image
Sajjad Ali

Thanks for the reply. This is the findOne() method I tried exactly like your post but it's not working as expected.

async findOne(ctx){
  const user = ctx.state.user;

  ctx.query.filters = {
      ...(ctx.query.filters || {}),
      user_id: user.id
  };

  return super.findOne(ctx);
}
Enter fullscreen mode Exit fullscreen mode

As mentioned, It's fetching invoices which doesn't belong to the user. For example, logged-in User 1 can fetch single invoice which belongs to User 2.

But find all method works fine and returns the posts which only belongs to User 1.

async find(ctx){
  const user = ctx.state.user;

  ctx.query.filters = {
      ...(ctx.query.filters || {}),
      user_id: user.id
  };

  return super.find(ctx);
}
Enter fullscreen mode Exit fullscreen mode

So the filter works great for find() method but all other methods (findOne, update, delete) are open to all users. User 1 can find/update/delete invoice which belongs to User 2.

I have been testing in Insomnia. That's why I came up with that crazy method. Which I know is not the best way at all but hoping to get some better suggestion.

I just tried this.

async findOne(ctx){
  const user = ctx.state.user;
  const { id } = ctx.params;

  const invoice = await strapi.entityService.findOne('api::invoice.invoice', id, {
    filters: { user_id: { $eq: user.id, } },
  })

  return invoice;
}
Enter fullscreen mode Exit fullscreen mode

Again same result. User 1 can fetch User 2 invoice.

Thread Thread
 
erfanatp profile image
Erfan Attarzadeh

Did you find any solutions?

Collapse
 
sharkfin009 profile image
Sharkfin

saved me, thanks Friend

Collapse
 
paratron profile image
Christian Engel

My pleasure, always trying to help :)

Collapse
 
j2l profile image
Philippe Manzano

Thank you!
It works fine when using the web (frontend users), but admin API token seems broken after this change (500, internal error). I guess admin user (API token) is not evaluatable as owner.
Why not going with policies? It seems hard but wraps around the controller: docs.strapi.io/developer-docs/late...

Collapse
 
sajjadalis profile image
Sajjad Ali • Edited

Thank you for this article. Really helpful. Though I have few issues.

While it working great for find() method and showing posts which belongs to the user but findOne() and update() method is working for posts which doesn't belong to the logged-in user.

for example. When I make request to /api/invoices/ route with jwt token. It works fine and only fetch invoices which belongs to that user.id. But when I make request to /api/invoices/1. It's fetching single invoice which doesn't belong to logged-in user. In other words, It's fetching all single invoices and update() method have same issue, user can update all invoices.

The code is exact copy/paste except owner is replaced with user_id: user.id. Any idea, what I might be doing wrong?

Thanks

Collapse
 
paratron profile image
Christian Engel

Without actually seeing your code, I cannot make any comments about what might be wrong, sorry :)

Collapse
 
kazdan1994 profile image
Arthur Jacquemin • Edited

more simple

async findOne(ctx) {
    const {id} = ctx.params;
    const {user} = ctx.state;

    const entity = await strapi.query(UID).findOne({
      where: {
        id,
        user: user.id,
      }
    })

    if (entity === null) {
      return ctx.notFound();
    }

    return super.findOne(ctx);
  },
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mhelbich profile image
Matthias H.

This does not seem to work if your users-permissions-plugin Users model does not have the find/findOne permissions - unless I'm doing something wrong.
I needed to do something like

await strapi.entityService.findMany(
        "api::content.content",
        {
          filters: {
            user: {
              id: {
                $eq: user.id,
              },
            },
          },
        }
      );
Enter fullscreen mode Exit fullscreen mode

In this case, my content type has a many-to-one relation to the User from U&P plugin.
Is there any better way to do this? Via Policies?
I don't want to specifically have to check the DB on every request (be it find, findOne, update, delete, etc.), but is sorta the best I've found so far.

Collapse
 
odifyltsaeb profile image
Alan

Man, I'm trying to do this, but it just does not work. I can see queries from postman hitting the endpoint, the authenticated user is there, but the filtering is just not happening. And I have no idea why. I even created question on strapi forums, but have had no luck getting answers : forum.strapi.io/t/im-following-exa...

Collapse
 
odifyltsaeb profile image
Alan

Allright, just thought I'd read the quirks part of your series and whoa... once I enabled the permissions for users, everything started working...

Collapse
 
spiralcrew profile image
SpiralCrew • Edited

Thanks for the article. I am now learning about policies and middlewares in Strapi 4, and that makes it a bit easier.

For example:

// setUser middleware

module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    const user = ctx.state.user;
    ctx.request.body.data.user = user.id;

    return next();
  };
};
Enter fullscreen mode Exit fullscreen mode

You could do something similar for other endpoints and add those middleware to your routes, like:

import { factories } from "@strapi/strapi";

export default factories.createCoreRouter("api::query.query", {
  config: {
    create: {
      middlewares: ["global::set-user"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
erfanatp profile image
Erfan Attarzadeh

You saved my life. Thank you. Although, the create() response was different to the default response. Default response has a data inside data key.

Collapse
 
phtremor profile image
PHTremor

Thank You.