strapi / documentation

Strapi Documentation
https://docs.strapi.io
Other
1.02k stars 1.09k forks source link

strapi v4 missing Create is owner policy documentation #600

Closed kodeli-lab closed 1 year ago

kodeli-lab commented 2 years ago

Summary

Hi, The new strapi v4 documentation is missing how to implement author by default in code. the new doc still reference the v3 apply author by default.

https://docs.strapi.io/developer-docs/latest/guides/is-owner.html

Why is it needed?

strapi v4 app crash when you try to implement Create is owner policy

Suggested solution(s)

Please work on the documentation for this because this is a vital feature most developers or users of strapi would be looking out for when migrating from v3 to v4.

Related issue(s)/PR(s)

strapi v4 app crash when you try to implement Create is owner policy

AhmadrezaPRO commented 2 years ago

After so much headache i made it work for me! my project is simple tho. but the basics should be the same. I hope it helps you and other people. this code works but i made it work with 10 hour trial and error! so feel free to change it in your way and make it better. My api name is event here. you should edit that with your api name and i also populate my events with image and user object to have those info when using GET in my api:

// path:\src\api\event\controllers
/**
 *  event controller
 */

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

// module.exports = createCoreController('api::event.event');

module.exports = createCoreController('api::event.event', ({strapi}) => ({
  //Find with populate ----------------------------------------
  async find(ctx) {
    const populateList = [
      'image',
      'user',
    ]
    // Push any additional query params to the array
    populateList.push(ctx.query.populate)
    ctx.query.populate = populateList.join(',')
    // console.log(ctx.query)
    const content = await super.find(ctx)
    return content
  },

  // Create user event----------------------------------------
  async create(ctx) {
    let entity;
    ctx.request.body.data.user = ctx.state.user;
    entity = await super.create(ctx);
    return entity;
  },
  // Update user event----------------------------------------
  async update(ctx) {
    let entity;
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    };
    const events = await this.find({query: query});
    console.log(events);
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't update this entry`);
    }
    entity = await super.update(ctx)
    return entity;
  },

  // Delete a user event----------------------------------------
  async delete(ctx) {
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    }
    const events = await this.find({query: query});
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't delete this entry`);
    }
    const response = await super.delete(ctx);
    return response;
  },
  // Get logged in users----------------------------------------
  async me(ctx) {
    const user = ctx.state.user;
    if (!user) {
      return ctx.badRequest(null, [
        {messages: [{id: "No authorization header was found"}]},
      ]);
    }
    const query = {
      filters: {
        user: {id: user.id}
      }
    }
    const data = await this.find({query: query});
    if (!data) {
      return ctx.notFound();
    }
    const sanitizedEntity = await this.sanitizeOutput(data, ctx);
    return this.transformResponse(sanitizedEntity);
  },
}));
derrickmehaffy commented 2 years ago

I would honestly suggest using either policies or route middlewares and not overrideing the controllers.

Once we get around to refactoring this guide, this is what we -should- do. The controller method is from strapi v3 beta times and is very outdated.

davidparys commented 2 years ago

I've created a global policy and started calling it in ./src/index.js which looks like

"use strict";

module.exports = {
  /**
   * An asynchronous register function that runs before
   * your application is initialized.
   *
   * This gives you an opportunity to extend code.
   */
  register({ strapi }) {
    const extensionService = strapi.plugin("graphql").service("extension");

    extensionService.use({
      resolversConfig: {
        "Query.issues": {
          policies: ["global::isOwner"],
          auth: true,
        },
      },
    });
  },

  /**
   * An asynchronous bootstrap function that runs before
   * your application gets started.
   *
   * This gives you an opportunity to set up your data model,
   * run jobs, or perform some special logic.
   */
  bootstrap(/*{ strapi }*/) {},
};

The policy is called correctly (this works for graphQL). But I can't figure a way out to return only the owners blobs only, in v3 there was a ctx.body where we could just filter stuff out, but here I can't seem to find it.

My Policy so far

"use strict";

/**
 * `isOwner` policy.
 */

module.exports = (policyContext, config, { strapi }) => {
  // Add your own logic here.
  strapi.log.info("In isOwner policy.");
  strapi.log.info("########################################.");

  console.log("USER ID", policyContext.state.user.id); // Getting the user ID
  console.log("STATE", policyContext); // Can't find the node context here

  strapi.log.info("########################################.");

  const canDoSomething = true;

  if (canDoSomething) {
    return true;
  }

  return false;
};

EDIT: I've migrated my v3 code which looks like this:

module.exports = async (ctx, next, args) => {
  if (!ctx?.state?.user?.id) {
    return ctx.unauthorized("You are not allowed to perform this action.");
  }
  const { id, role } = ctx.state.user;

  if (role.name === "adminAPI") {
    return await next();
  } else {
    ctx.query.customerID = id;
  }

  if (ctx.params.id) {
    const owner = ctx.response.body.get("customerID");
    if (owner !== id && role !== "adminAPI") {
      return ctx.unauthorized("You are not allowed to perform this action.");
    }
  }
  await next();

  // console.log("the user", ctx.state.user);
  // console.log("the body", ctx.response.body);
  // if (role !== "adminAPI") {
  //   ctx.query.customerID = id;
  // }
  // await next();

  // if (ctx.params.id) {
  //   if (owner !== id && role !== "administrator") {
  //     return ctx.unauthorized("You are not allowed to perform this action.");
  //   }
  // }
};

To something like that in v4 (misses the error messages and stuff but you could adapt it to your project). It's still far from ideal as the graphQL request needs to have filters in place for me to be able to edit them. I just want to do that automatically without adding ID variables.

"use strict";

/**
 * `isAuthor` policy.
 */

module.exports = (policyContext, config, { strapi }) => {
  // Add your own logic here.
  strapi.log.info("In isOwner policy.");
  strapi.log.info("########################################.");

  console.log("USER ID", policyContext.state.user.id);
  console.log("STATE", policyContext.args.filters);

  strapi.log.info("########################################.");

  const userID = policyContext.state.user.id;

  // Tenant owner can only see their own issues

  policyContext.args.filters.tenant.user.id = userID;

  return true;
};
Kunalgoel9 commented 2 years ago

After so much headache i made it work for me! my project is simple tho. but the basics should be the same. I hope it helps you and other people. this code works but i made it work with 10 hour trial and error! so feel free to change it in your way and make it better. My api name is event here. you should edit that with your api name and i also populate my events with image and user object to have those info when using GET in my api:

// path:\src\api\event\controllers
/**
 *  event controller
 */

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

// module.exports = createCoreController('api::event.event');

module.exports = createCoreController('api::event.event', ({strapi}) => ({
  //Find with populate ----------------------------------------
  async find(ctx) {
    const populateList = [
      'image',
      'user',
    ]
    // Push any additional query params to the array
    populateList.push(ctx.query.populate)
    ctx.query.populate = populateList.join(',')
    // console.log(ctx.query)
    const content = await super.find(ctx)
    return content
  },

  // Create user event----------------------------------------
  async create(ctx) {
    let entity;
    ctx.request.body.data.user = ctx.state.user;
    entity = await super.create(ctx);
    return entity;
  },
  // Update user event----------------------------------------
  async update(ctx) {
    let entity;
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    };
    const events = await this.find({query: query});
    console.log(events);
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't update this entry`);
    }
    entity = await super.update(ctx)
    return entity;
  },

  // Delete a user event----------------------------------------
  async delete(ctx) {
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    }
    const events = await this.find({query: query});
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't delete this entry`);
    }
    const response = await super.delete(ctx);
    return response;
  },
  // Get logged in users----------------------------------------
  async me(ctx) {
    const user = ctx.state.user;
    if (!user) {
      return ctx.badRequest(null, [
        {messages: [{id: "No authorization header was found"}]},
      ]);
    }
    const query = {
      filters: {
        user: {id: user.id}
      }
    }
    const data = await this.find({query: query});
    if (!data) {
      return ctx.notFound();
    }
    const sanitizedEntity = await this.sanitizeOutput(data, ctx);
    return this.transformResponse(sanitizedEntity);
  },
}));

Thanks bro it really helped a lot !!

leoikeh99 commented 2 years ago

After so much headache i made it work for me! my project is simple tho. but the basics should be the same. I hope it helps you and other people. this code works but i made it work with 10 hour trial and error! so feel free to change it in your way and make it better. My api name is event here. you should edit that with your api name and i also populate my events with image and user object to have those info when using GET in my api:

// path:\src\api\event\controllers
/**
 *  event controller
 */

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

// module.exports = createCoreController('api::event.event');

module.exports = createCoreController('api::event.event', ({strapi}) => ({
  //Find with populate ----------------------------------------
  async find(ctx) {
    const populateList = [
      'image',
      'user',
    ]
    // Push any additional query params to the array
    populateList.push(ctx.query.populate)
    ctx.query.populate = populateList.join(',')
    // console.log(ctx.query)
    const content = await super.find(ctx)
    return content
  },

  // Create user event----------------------------------------
  async create(ctx) {
    let entity;
    ctx.request.body.data.user = ctx.state.user;
    entity = await super.create(ctx);
    return entity;
  },
  // Update user event----------------------------------------
  async update(ctx) {
    let entity;
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    };
    const events = await this.find({query: query});
    console.log(events);
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't update this entry`);
    }
    entity = await super.update(ctx)
    return entity;
  },

  // Delete a user event----------------------------------------
  async delete(ctx) {
    const {id} = ctx.params;
    const query = {
      filters: {
        id: id,
        user: {id: ctx.state.user.id}
      }
    }
    const events = await this.find({query: query});
    if (!events.data || !events.data.length) {
      return ctx.unauthorized(`You can't delete this entry`);
    }
    const response = await super.delete(ctx);
    return response;
  },
  // Get logged in users----------------------------------------
  async me(ctx) {
    const user = ctx.state.user;
    if (!user) {
      return ctx.badRequest(null, [
        {messages: [{id: "No authorization header was found"}]},
      ]);
    }
    const query = {
      filters: {
        user: {id: user.id}
      }
    }
    const data = await this.find({query: query});
    if (!data) {
      return ctx.notFound();
    }
    const sanitizedEntity = await this.sanitizeOutput(data, ctx);
    return this.transformResponse(sanitizedEntity);
  },
}));

Thanks, this worked seamlessly for me, and i'm guessing we are taking the same nextjs course (just guessing), thanks a lot

wagneraraujo commented 2 years ago

Hello friends, please help me. I could not return the data created by each logged in user. Where can I be wrong?


const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController(
  "api::pessoalfinanca.pessoalfinanca",
  ({ strapi }) => ({
    async create(ctx) {
      let entity;
      ctx.request.body.data.colaborador = ctx.state.user;
      entity = await super.create(ctx);
      return entity;
    },
    async find(ctx) {
      const { data, meta } = await super.find(ctx);
      return { data, meta };
    },
  })
);
jeremybradbury commented 2 years ago

Thanks for this @AhmadrezaPRO!

Since my use case slightly varied, I moved the filter to the find, so users can only see data they created, this filter is then called again through this.find() for deletes and updates. But you could also make a filteredFind that you reuse instead of the filter itself or make a getQuery object formatter accepting the id input to make it more DRY.

createCoreController("api::object.object", ({ strapi }) => ({
  //Find with ownership filter----------------------------------------
  async find(ctx) {
    const { id } = ctx.params;
    const query = {
      filters: {
        id,
        user: { id: ctx.state.user.id },
      },
    };
    const objects = await this.find({ query });
    console.log(objects);
    return objects;
  },
  // Create user object----------------------------------------
  async create(ctx) {
    let entity;
    ctx.request.body.data.user = ctx.state.user; // assign my user as owner
    entity = await super.create(ctx);
    return entity;
  },
  // Update user object----------------------------------------
  async update(ctx) {
    let entity;
    const objects = await this.find({ query: query });
    console.log(objects);
    if (!objects.data || !objects.data.length) {
      return ctx.unauthorized(`You can't update this entry`);
    }
    entity = await super.update(ctx);
    return entity;
  },
  // Delete a user object----------------------------------------
  async delete(ctx) {
    const objects = await this.find({ query: query });
    if (!objects.data || !objects.data.length) {
      return ctx.unauthorized(`You can't delete this entry`);
    }
    const response = await super.delete(ctx);
    return response;
  },
  // Get logged in users----------------------------------------
  async me(ctx) {
    const user = ctx.state.user;
    if (!user) {
      return ctx.badRequest(null, [
        { messages: [{ id: "No authorization header was found" }] },
      ]);
    }
    const data = await this.find({ query: query });
    if (!data) {
      return ctx.notFound();
    }
    const sanitizedEntity = await this.sanitizeOutput(data, ctx);
    return this.transformResponse(sanitizedEntity);
  },
}));

However, I am trying this with the GraphQL plugin and it doesn't filter anything, likely because it's in a RESTful Controller.

I think this partly why @derrickmehaffy mentioned policies or middleware.

How do we actually fix this globally (not just REST which we plan to disable)? -- It seems like policies are the solve from here, like this?

But also, how do we override per object?

it looks like this should work for me...

const { createCoreRouter } = require("@strapi/strapi").factories;

module.exports = createCoreRouter("api::object.object", {
  config: {
    policies: ["global::is-owner"],
  },
});

...but the policy isn't properly formatted or written in TypeScript

module.exports = async (ctx, next) => {
  // must be authenticated user
  if (!ctx.state.user) {
    return ctx.unauthorized();
  }
  const collection = ctx.request.route.controller;
  if (!strapi.services[collection])
    return ctx.notFound(`Collection ${collection} not found`);
  const [content] = await strapi.services[collection].find({
    id: ctx.params.id,
    "user.id": ctx.state.user.id,
  });
  if (!content) {
    return ctx.forbidden(`Only the user can do this`);
  }
  return await next();
};

and this is a new type def for a Policy, which is essentially just an input type, not an actual type (where input types like cfg are omitted/implied elsewhere, for extra confusion):

interface PolicyContext extends BaseContext {
  type: string;
  is(name): boolean;
}

export type Policy = (ctx: PolicyContext, { strapi: Strapi }) => boolean | undefined;

The BaseContext is simply a request & response object, which doesn't make sense for a Policy object, just one of it's input params. The policies.js doesn't even really return a Policy from the policies.d.ts file, it defines it's own typing through member functions (keys/get/getAll/set/add/extend), which have no interface (nor do they exist in BaseContext), even though they're clearly shared.

I'm really unclear on how to implement policies, models or middleware solutions in v4. -- could we see an example somewhere?

FWIW, the docs don't really seem to meet the v4 value prop explained here: https://strapi.io/blog/announcing-strapi-v4, which still seems to be missing most of it's public documentation & tutorials. I am trying to build an open source framework combining it with Storybook and Svelte. But because of the missing docs problem, I may need to bail on it while it's still a POC as this is for work, not just some side project. Nearly everything public is for v3, so docs are stuck in maintainer heads (and the professional services they sell), and everyone else is scared to upgrade, using TS conversion tools with V3 still works.

Perhaps a good exercise for the team is going through old use cases like I posted above, and adding the new docs to the existing forum posts as that's where much of the v3 docs are found and all those users, and thread's readers need to upgrade?

IME, TypeScript should help explain & clarify our code, not make it more confusing, and when it does we need to document with code comments and wikis etc.

I'm often busy working on games and stuff when I'm not build web apps, but I also love helping with documentation and example code.

Let me know how I can help.

jeremybradbury commented 2 years ago

@davidparys I tried to make your solution work, it was the closest I've come to success.

Here is the problem as I see it:

  1. Controllers don't work with GraphQL
  2. User relations are stripped from the user object in auth
  3. There's no clear way to re-attach them cleanly, only role is allowed

The solution seems to be a clean blacklist & whitelist option (like a checkbox in the admin panel) to include objects we want related to our users (or even just added to the ctx.state object) like their profile, to keep us from overloading the user object with too many fields and lacking lists entirely.

Here is what I did based on @davidparys work:

module.exports = (ctx, config, { strapi }) => {
  // Add more logic here.
  strapi.log.info("In isOwner policy.");
  strapi.log.info("########################################.");

  console.log("USER ID", ctx.state.user.id);
  console.log("isProfile", ctx.info.fieldName.includes("profile"));

  console.log("ctx", Object.keys(ctx));
  console.log("ctx.args", Object.entries(ctx.args));
  console.log("ctx.context", Object.entries(ctx.context));
  console.log("ctx.info", Object.entries(ctx.info));
  console.log("ctx.state", Object.entries(ctx.state));

  console.log("strapi", Object.keys(strapi));
  console.log("config", config);

  strapi.log.info("########################################.");

  return true;
};

Here is the output:

[2022-01-25 16:31:21.910] info: In isOwner policy.
[2022-01-25 16:31:21.910] info: ########################################.
USER ID 1
isProfile true
ctx [
  'is',      'type',
  'parent',  'args',
  'context', 'info',
  'state',   'http'
]
ctx.args [
  [ 'pagination', {} ],
  [ 'sort', [] ],
  [ 'publicationState', 'live' ]
]
ctx.context [
  [
    'state',
    {
      route: [Object],
      user: [Object],
      isAuthenticated: true,
      auth: [Object]
    }
  ],
  [
    'koaContext',
    {
      request: [Object],
      response: [Object],
      app: [Object],
      originalUrl: '/graphql',
      req: '<original node req>',
      res: '<original node res>',
      socket: '<original node socket>'
    }
  ]
]
ctx.info [
  [ 'fieldName', 'profiles' ],
  [ 'fieldNodes', [ [Object] ] ],
  [ 'returnType', TaskEntityResponseCollection ],
  [ 'parentType', Query ],
  [ 'path', { prev: undefined, key: 'tasks', typename: 'Query' } ],
  [
    'schema',
    GraphQLSchema {
      __validationErrors: [],
      description: undefined,
      extensions: [Object: null prototype],
      astNode: [Object],
      extensionASTNodes: [],
      _queryType: Query,
      _mutationType: Mutation,
      _subscriptionType: undefined,
      _directives: [Array],
      _typeMap: [Object: null prototype],
      _subTypeMap: [Object: null prototype] {},
      _implementationsMap: [Object: null prototype] {}
    }
  ],
  [ 'fragments', [Object: null prototype] {} ],
  [ 'rootValue', undefined ],
  [
    'operation',
    {
      kind: 'OperationDefinition',
      operation: 'query',
      name: undefined,
      variableDefinitions: [],
      directives: [],
      selectionSet: [Object],
      loc: [Object]
    }
  ],
  [ 'variableValues', {} ],
  [
    'cacheControl',
    {
      setCacheHint: [Function: setCacheHint],
      cacheHint: [Object],
      cacheHintFromType: [Function: memoizedCacheAnnotationFromType]
    }
  ]
]
ctx.state [
  [ 'route', { info: [Object] } ],
  [
    'user',
    {
      id: 1,
      username: '4567456',
      email: 'no@thx',
      provider: 'local',
      password: 'h@h@',
      resetPasswordToken: null,
      confirmationToken: null,
      confirmed: true,
      blocked: false,
      createdAt: '2022-01-24T18:25:09.498Z',
      updatedAt: '2022-01-25T00:10:22.806Z',
      role: [Object]
    }
  ],
  [ 'isAuthenticated', true ],
  [ 'auth', { strategy: [Object], credentials: [Object] } ]
]
strapi [
  'dirs',            'container',
  'isLoaded',        'reload',
  'server',          'fs',
  'eventHub',        'startupLogger',
  'log',             'cron',
  'telemetry',       'admin',
  'app',             'components',
  'webhookRunner',   'db',
  'store',           'webhookStore',
  'entityValidator', 'entityService'
]
config {}
[2022-01-25 16:31:21.924] info: ########################################.

The response is still set to a default of 404 and we don't have any data results yet, the query hasn't begun. I am still getting all results not 1 of 2. Perhaps we need a plugin running after graphql? Are they chained together or run in parallel?

If we can't do this in the controller, and we can't do this in a policy any more, a middleware still doesn't help much since it's based around the request/response and not hooked in the right place... what happened to models, those seemed to work for REST & GraphQL in v3?

Hooks are my best guess: https://docs.strapi.io/developer-docs/latest/development/backend-customization/models.html#available-lifecycle-events

This is where i'm going next. One last college try before I move back to v3 or some other backend tooling... perhaps Ghost or even Sanity / Contentful, worst case I know wordpress with gastsby/graphql plugin will work perfect for our needs, regardless of its ugly schema.

jeremybradbury commented 2 years ago

So after playing with Lifecycle events... I see the example code on that page looks incomplete, especially the second file using strapi with no import or definition, and shows TypeScript annotations inside .js named files. So it seems it was never placed into a working IDE before publishing it.

But now I see we no longer have access to the user's data in this place, so there is no way to compare the user id to the data for filtering.

There is also no clear way of preventing a create/update/delete inside a before is it just return false or throw?

I'm gonna reiterate my point from above.

The [real] solution seems to be a clean blacklist & whitelist option (like a checkbox in the admin panel) to include objects we want related to our users like their profile, to keep us from overloading the user object with too many fields and lacking lists entirely.

This seems to be broken by an opinion that we shouldn't associate things to the user object at all, unless it's a role.

There is no workaround for this breaking opinion. I just wasted days looking for one.

Being able to limit users to reading/updating/deleting data they create over both REST & GraphQL. adding ownership in beforeCreate etc are key features that work in v3.

To be clear, with GraphQL queries I ran these hooks:

  beforeFindMany(event) {
    const { data, where, select, populate } = event.params;
    console.log({ data, where, select, populate });
    console.log({event});
  },
  afterFindMany(event) {
    const { result, params } = event;
    console.log({result, params});
  },

and got this result:

{
  data: undefined,
  where: undefined,
  select: undefined,
  populate: undefined
}
{
  event: {
    action: 'beforeFindMany',
    model: {
      singularName: 'profile',
      uid: 'api::profile.profile',
      tableName: 'profiles',
      attributes: [Object],
      lifecycles: [Object],
      columnToAttribute: [Object]
    },
    params: {
      orderBy: [],
      offset: 0,
      limit: 10,
      filters: [Function (anonymous)]
    }
  }
}
{
  result: [
    {
      ...omitted profile object
    },
    {
       ...someone else's profile object, i shouldn't see
    }
  ],
  params: {
    orderBy: [],
    offset: 0,
    limit: 10,
    filters: [Function (anonymous)]
  }
}

There is no access to context or data, even the user who authorized the request. The only place I can find all these are in a controller, which again doesn't work with GraphQL.

I may have bailed on using this for work (i can't ethically justify wasting more of my employer's time on this), but if I can be of any more help patching up this huge hole in a common v3 use case, please mention me, or let me know how I can help.

derrickmehaffy commented 2 years ago

@jeremybradbury

I'm just kinda catching myself up on this issue thread, usually I'd advise this level and depth of discussion on our forum or discord and not in github issues.

especially the second file using strapi with no import or definition

This is normal as you don't need to import or require in the backend for usage with strapi. It's a Koa thing.


I would always recommend using route policies and middlewares for something like this and never any deeper (controllers, services, lifecycles, ect).

We are aware of issues with user population including the profile, that isn't a documentation problem but a code problem.

jeremybradbury commented 2 years ago

We are aware of issues with user population including the profile, that isn't a documentation problem but a code problem.

@derrickmehaffy how can i help?

I found this as a core feature on the roadmap since 2018, for v3, for which there was only (acceptable) doc/plugin solutions for it, which no longer work.

https://portal.productboard.com/strapi/1-roadmap/c/44-data-ownership

The roadmap only gives me the ability to complain, not offer to help or place a bounty.

I am happy to go at a problem, in any language or environment, if someone can point me to the relevant folders or provide me with documentation that helps.

derrickmehaffy commented 2 years ago

We are aware of issues with user population including the profile, that isn't a documentation problem but a code problem.

@derrickmehaffy how can i help?

I found this as a core feature on the roadmap since 2018, for v3, for which there was only (acceptable) doc/plugin solutions for it, which no longer work.

https://portal.productboard.com/strapi/1-roadmap/c/44-data-ownership

The roadmap only gives me the ability to complain, not offer to help or place a bounty.

I am happy to go at a problem, in any language or environment, if someone can point me to the relevant folders or provide me with documentation that helps.

We simply haven't had the time to adjust the guide in the docs for v4 as it's quite low priority given the other issues in the U&P plugin right now.

I don't have suggestions right now as I myself am swamped with other things.

jeremybradbury commented 2 years ago

Can I help you add a bounty system to your roadmap?

This way ppl can put money down on features that (mostly) maintainers can get paid for features that are the most important, and open source devs can be motivated to learn the internals to fix something, possibly become another maintainer by helping with the smaller, unhandled bounties.

I see myself in both categories, wanting to pay bounty on this, and willing to build it if I could justify the time away from other things. Also I actually like writing docs along with my code, but it's hard to understand well enough to write about them without any understanding in the first place.

You could probably get help with these kinds of issues:

given the other issues in the U&P plugin right now.

I'm just offering a hand, but I can't help without a bit of context. I get it, the training paradox of needing to sacrifice some time to get more help.

I honestly wish I could be more help without just digging into the core like it owes me money in my free time.

You know where to find me.

derrickmehaffy commented 2 years ago

We don't know bounty systems for feature requests, bugs, or security related stuff.

Ideally we internally need to be the primary source of security related fixes but some in the community do tend to help out such as @iicdii (especially lately on some of the U&P problems) but we don't like introducing the money side of things in there.

jeremybradbury commented 2 years ago

Cool... well if you ever do want some help. Even volunteer help, don't be afraid to ask those clearly offering you help.

You presented the lack of spare time and the overwhelming workload. I offered to help & solution that allows us to create supply and demand over features, in a transparent way (like crypto projects).

I'm just trying to offer some simple/common solutions along with presenting my own problems.

If you don't want help you don't want help. If you do, it's easy to find me.

GLHF

derrickmehaffy commented 2 years ago

Cool... well if you ever do want some help. Even volunteer help, don't be afraid to ask those clearly offering you help.

You presented the lack of spare time and the overwhelming workload. I offered to help & solution that allows us to create supply and demand over features, in a transparent way (like crypto projects).

I'm just trying to offer some simple/common solutions along with presenting my own problems.

If you don't want help you don't want help. If you do, it's easy to find me.

GLHF

Trust me we do want it, but to layout the context also requires a lot of time too. We are trying to open source more of our internal processes (starting by open sourcing our handbook: https://handbook.strapi.io) but it takes time and our team is still really small.

Soon I hope we can get to this but higher priority tasks are eating up time. Hopefully I can count on you in the future.

echofoxtrotpl commented 2 years ago

I think I made it. Here is the example of working user-based policy with graphql.

I have events collection with field creator which is: relation with User (from: users-permissions) The goal is to allow only creators to edit their events.

src/index.js

module.exports = {

  register({ strapi }) {
    const extensionService = strapi.plugin('graphql').service('extension');

    extensionService.use({
      resolversConfig: {
        'Mutation.updateEvent': {
          policies: [
            async (context) => {
              const entity = await strapi.db.query('api::event.event').findOne({
                where: { id: context.args.id },
                populate: { creator: true },
              });

              return entity.creator.id === context.context.state.user.id;
            }
          ]
        }
      }
    });
  },
  bootstrap(/*{ strapi }*/) {},
};

user.id is stored automaticly in JWT. We can extract it from context.context.state.user.id. If it matches entity.creator.id mutation executes. Otherwise we get this response:

"errors": [
    {
      "message": "Policy Failed",
      "extensions": {
        "error": {
          "name": "PolicyError",
          "message": "Policy Failed",
          "details": {}
        },
        "code": "FORBIDDEN"
      }
    }
  ],
  "data": {
    "updateEvent": null
  }
}

You can also use it with queries - more info.

destroyer22719 commented 2 years ago

@AhmadrezaPRO Thank you so much! You're an angel!

destroyer22719 commented 2 years ago

After so much headache i made it work for me! my project is simple tho. but the basics should be the same. I hope it helps you and other people. this code works but i made it work with 10 hour trial and error! so feel free to change it in your way and make it better. My api name is event here. you should edit that with your api name and i also populate my events with image and user object to have those info when using GET in my api:

// path:\src\api\event\controllers
/**
 *  event controller
 */

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

// module.exports = createCoreController('api::event.event');

module.exports = createCoreController('api::event.event', ({strapi}) => ({
  // Create user event----------------------------------------
  async create(ctx) {
    let entity;
    ctx.request.body.data.user = ctx.state.user;
    entity = await super.create(ctx);
    return entity;
  }

@AhmadrezaPRO I'm having problems with the create controller. When the user field is a relation to the User collection data type it doesn't seem to set the user to the current user at all.

DesignfulDev commented 2 years ago

@AhmadrezaPRO I'm having problems with the create controller. When the user field is a relation to the User collection data type it doesn't seem to set the user to the current user at all.

@destroyer22719 In the Admin panel, go to Settings > Users & Permissions Plugin > Roles > Authenticated.

Then under Permissions > User-permissions make sure the action "find" is checked. Save.

This works BUT - seems to me - it exposes user data which was hidden by default on the API. With the "find" action allowed for authenticated users, they are able to query all users in the system by GETting '/api/users', where before they could only GET '/api/users/me'.

@AhmadrezaPRO do you think there's a way around that?

ijja commented 2 years ago

The following code worked like a charm for me, i came up with this solution after going through the core code, in v4 you need to specify your config for each service name in your route file ~/routes/service.js like the following :

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

module.exports = createCoreRouter('api::service.service', {
  config: {
    find: {policies: ['is-owner']},
    findOne: {policies: ['is-owner']}
  },
});
JulianAtTheFrontend commented 2 years ago

This is the best solution, works perfectly.

I think I made it. Here is the example of working user-based policy with graphql.

I have events collection with field creator which is: relation with User (from: users-permissions) The goal is to allow only creators to edit their events.

src/index.js

module.exports = {

  register({ strapi }) {
    const extensionService = strapi.plugin('graphql').service('extension');

    extensionService.use({
      resolversConfig: {
        'Mutation.updateEvent': {
          policies: [
            async (context) => {
              const entity = await strapi.db.query('api::event.event').findOne({
                where: { id: context.args.id },
                populate: { creator: true },
              });

              return entity.creator.id === context.context.state.user.id;
            }
          ]
        }
      }
    });
  },
  bootstrap(/*{ strapi }*/) {},
};

user.id is stored automaticly in JWT. We can extract it from context.context.state.user.id. If it matches entity.creator.id mutation executes. Otherwise we get this response:

"errors": [
    {
      "message": "Policy Failed",
      "extensions": {
        "error": {
          "name": "PolicyError",
          "message": "Policy Failed",
          "details": {}
        },
        "code": "FORBIDDEN"
      }
    }
  ],
  "data": {
    "updateEvent": null
  }
}

You can also use it with queries - more info.

pwizla commented 2 years ago

Thank you for opening this issue, and sorry for not replying earlier. The documentation team, unfortunately, didn't have time to focus on issues before now.

We still haven't the resources to work on updating this guide for now, but it seems like some of you have found a solution.

Would you like to update that documentation yourself? Community contributions to the Strapi documentation are always welcome.

Feel free to use the various contribution resources below:

Thank you!

robbyemmert commented 2 years ago

The lack of clarity on this issue is a dealbreaker for me and my team. Here are the solutions we've looked into:

  1. Policies You have to manually add a policy reference to each route and each graphql query/mutation separately. There is no way to add policies to all routes or all graphQL queries/mutations. Adding policies to plugin routes is complicated to impossible.

    1. Middleware This doesn't work well for the same reason as Policies, but there is no way to access the body in a graphQL request. This means we'd have to write 2 separate middlewares.
    2. Lifecycle Hooks This would be a great option as it runs no matter how the event was triggered (GraphQL, Rest, Cron job, or other), but there is no way to access headers or authentication information in lifecycle hooks. There is also no documented way to stop execution of an event.

    If there was a viable solution or workaround in any of these directions that didn't involve route-by-route config, or monkey-patching core functionality, we would have a path forward. As it is, we are stuck.

I would recommend prioritizing a standard workaround for this issue. It sounds like there are people willing to help, but there just isn't any clarity on direction.

As it is, I am looking at forking Strapi, and steering my team away from Strapi on future projects. Otherwise, if there is a solution, or if there is something I or my team can do to help create a solution, do let me know how we can help!

robbyemmert commented 2 years ago

We have found a temporary solution as we migrate away from Strapi.

Disclaimer: This solution isn't great, and will probably break next time Strapi updates.

We are extending and replacing large swaths of the GraphQL plugin, using the v4 extensions API. Long story short:

  1. Adding the ability to add global GraphQL middleware (not possible with stock Strapi)
  2. Registering global GraphQL middleware to add ownership parameters to all queries and mutations
  3. Adding traditional middleware to add ownership parameters to all requests

I will share as many details here as I can, as development progresses. Some of our code may be applicable to the core GraphQL plugin.

sparkdream commented 2 years ago

I have a Strapi REST backend and this v4 issue is(was) a showstopper for me.

By poking around I eventually discovered on the Strapi Discord that someone submitted a PR to update the isOwner guide on... January 28th 2022. Kudos to you nextrapi! I didn't see that PR mentioned above so here is the link:

https://github.com/strapi/documentation/pull/674

That proposed solution also requires per route modifications as the solutions above so it's not ideal but I briefly tested and it seems to work quite well. I think it will allow me to move forward with my current setup.

pwizla commented 2 years ago

Hello @sparkdream. Thank you for the message. I'm sorry that this issue is still causing trouble for some users but happy that PR 674 helped you (thank you again so much, nextrapi!) The documentation PR still hasn't been validated by our support or engineering team, and there are some open discussions, so I can't merge it with the official documentation content yet. I will do my best to have the PR move forward, but a significant portion of the Strapi team is on vacation at that time, and our bandwidth is limited. I will keep you posted.

uschtwill commented 2 years ago

@ijja What does your is-owner policy file look like?

uschtwill commented 2 years ago

This was really helpful: https://dev.to/paratron/limit-access-of-strapi-users-to-their-own-entries-298l

j2l commented 1 year ago

Any news? Controller changes (for frontend users) breaks API token access (admin user)

AminZibayi commented 1 year ago

This is the solution that I've come up with using policies as it is suggested:


"use strict";

const { PolicyError } = require("@strapi/utils").errors;

/**
 * `isOwner` policy.
 * CAUTION: This policy only applies to the REST API, GraphQL is unrestricted!!!
 */

module.exports = async (ctx, config, { strapi }) => {
  strapi.log.info("In isOwner policy.");

  const user = ctx.state.user;

  // find
  if (["GET"].includes(ctx.request.method) && !ctx.params.id) {
    ctx.request.query.filters = {
      ...(ctx.request.query.filters || {}),
      user: { id: user.id },
    };
  }
  // findOne, update, delete
  else if (
    ["PUT", "DELETE", "GET"].includes(ctx.request.method) &&
    ctx.params.id
  ) {
    let entity = await strapi.entityService.findOne(
      `api::${config.contentType}.${config.contentType}`,
      ctx.params.id,
      {
        populate: { user: true },
      }
    );
    if (entity.user?.id !== user.id)
      throw new PolicyError(
        "You do not have permission to access this entity",
        {}
      );
  }
  return true;
};

routes:

const { createCoreRouter } = require("@strapi/strapi").factories;

const isOwner = {
  name: "global::isOwner",
  config: { contentType: "publisher" },
};

module.exports = createCoreRouter("api::publisher.publisher", {
  config: {
    update: {
      policies: [isOwner],
    },
    delete: {
      policies: [isOwner],
    },
    find: {
      policies: [isOwner],
    },
    findOne: {
      policies: [isOwner],
    },
  },
});
alexey13 commented 1 year ago

Is it only for me or for all this not working any more? Strapi 4.10.7

rockbenben commented 1 year ago

isowner policy is not correct. I propose modifying it to re-fetch and compare within the controller.

async update(ctx) {
  const user = ctx.state.user;
  const {id : eventId} = ctx.request.params;
  const userprompt = await strapi.entityService.findOne(
    'api::userprompt.userprompt', 
    eventId, 
    { populate: ['owner'] }
  );
  if (userprompt.owner.id === user.id){
    return super.update(ctx);
  } else {
    console.log("You are not the owner")
    return false;
  }
}
pwizla commented 1 year ago

@janfess I suggest you look at PR #1816. It will be ready soon, and this is the recommended way to do an "is-owner policy" with Strapi v4. The present issue will be closed as soon as content from #1816 is merged into main.

pwizla commented 1 year ago

The related documentation is now live. I'm closing this issue. https://docs.strapi.io/dev-docs/backend-customization/middlewares#restricting-content-access-with-an-is-owner-policy

alidawud commented 1 year ago

@rockbenben Thank you for sharing your code. I've made some improvements around security and performance based on your code.

Note before using:

src/policies/isOwner.js

"use strict";

const { PolicyError } = require("@strapi/utils").errors;

/**
 * `isOwner` policy.
 * CAUTION: This policy only applies to the REST API, GraphQL is unrestricted!!!
 */

module.exports = async (ctx, config, { strapi }) => {
  strapi.log.info("In isOwner policy.");

  const user = ctx.state.user;
  if (!user.id) {
    throw new PolicyError("You must be logged in to access this resource", {});
  }

  // create
  if (["POST"].includes(ctx.request.method) && !ctx.params.id) {
    ctx.request.body.data.owner = user.id;
  }
  // find
  else if (["GET"].includes(ctx.request.method) && !ctx.params.id) {
    ctx.request.query.filters = {
      ...(ctx.request.query.filters || {}),
      owner: { '$eq': user.id }
    };
  }
  // findOne, update, delete
  else if (["PUT", "DELETE", "GET"].includes(ctx.request.method) && ctx.params.id) {
    let entity = await strapi.entityService.findOne(
      `api::${config.contentType}.${config.contentType}`,
      ctx.params.id,
      {
        fields: ['owner'],
        // populate: { owner: true }, // enable this if owner is a Relation
      }
    );
    if (!entity) {
      throw new PolicyError("This entity does not exist", {});
    } else if (entity.owner !== user.id) {
      throw new PolicyError(
        "You do not have permission to access this entity",
        {}
      );
    }
  } else {
    throw new PolicyError("This request is not supported", {});
  }

  return true;
};

Usage: src/api/message/routes/message.js

'use strict';

/**
 * message router
 */

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

const isOwner = {
    name: "global::isOwner",
    config: { contentType: "message" },
};

module.exports = createCoreRouter("api::message.message", {
    config: {
        create: {
            policies: [isOwner],
        },
        update: {
            policies: [isOwner],
        },
        delete: {
            policies: [isOwner],
        },
        find: {
            policies: [isOwner],
        },
        findOne: {
            policies: [isOwner],
        },
    },
});
derrickmehaffy commented 1 year ago

Indeed using policies is also an option and there isn't really a correct answer on policy vs middlware as both are viable options and is subjective to the users use-case.

Very good example though!