cloudcreativity / laravel-json-api

JSON API (jsonapi.org) package for Laravel applications.
http://laravel-json-api.readthedocs.io/en/latest/
Apache License 2.0
780 stars 109 forks source link

[Question] Efficient way to control exposure of sensitive relationships? #520

Closed steven-backerclub closed 4 years ago

steven-backerclub commented 4 years ago

First time posting an issue for the package, so I want to start by saying how much I appreciate the work that's gone/going into it. 👍 We're using it for a business application and are grateful to have the head start it provides.

Now onto the question...

In our application, we provide a number of resources that need to have their exposure controlled outside of authorizers/policies. Let's imagine this hypothetical example:

The application is a multi-vendor marketplace. It has users, some of which may own products (aka, be a vendor). A product hasMany variants. And finally, a vendor can choose to create coupons that relate to one or more variants. In summary, we may have an Eloquent setup that roughly resembles $user->products->variants->coupons.

Here's the exposure control part:

A product should only be exposed if:

A variant should only be exposed if:

A coupon should only be exposed if:

Now let's consider a few requests:

  1. GET .../api/products
    • If the user is an admin, we should be responding with all of the products in the app.
    • If the user is a regular user/guest, we should be responding with only the products that are published.
    • If the user is a vendor, we should respond with products that are published OR that they own.

How do we accomplish this?

It seems that one potential option is to utilize a global scope in the product's Adapter, so we add a scope that resembles:

class ProductsExposableToUserScope implements Scope

public function apply(Builder $builder, Model $model)
{
  $builder->where('published', true);
}

Wait... that won't work for a vendor trying to expose the products they own that aren't published yet. To know which products a user owns, we would need GET ...api/users/{record}/products, but this will still exclude any non-published products from the response due to our scope here.

Ok, so we update it to check if the user making the request is the owner of the resource:

class ProductsExposableToUserScope implements Scope

public function apply(Builder $builder, Model $model)
{
  $user = auth()->user ?: new User();

  $builder->where('published', true)
          ->orWhereHas('users', fn($usersQuery) => $usersQuery->where('user_id', $user->id));
}

Great. Wait.... not so great. An admin of the app still won't be able to see all of the products. Alright, let's check if the user making the request has the ability to 'viewAny' products. If they do, don't modify the $builder instance at all. If they don't, apply the scoping:

class ProductsExposableToUserScope implements Scope

public function apply(Builder $builder, Model $model)
{
  $user = auth()->user ?: new User();

  if ($user->cannot('viewAny', $model)) {
    $builder->where('published', true)
            ->orWhereHas('users', fn($usersQuery) => $usersQuery->where('user_id', $user->id));
}

A bit of an odd scope, but I suppose it'd work. If the $user is an admin and can 'viewAny', no scope is added to the $builder. Otherwise, we are checking for the published attribute or a users relation matching the authenticated user. It seems weird to be performing a Gate check for a scope, but alas it would get the job done.

  1. Time to load the details of a specific product with variant and coupon relations. We have 2 choices: GET ...api/products/{id}?include=variants.coupons or GET ...api/products/{id} + GET .../api/products/{id}/variants?include=coupons.
    • Now we have a tricky situation. On either of these requests, the coupons will be eager-loaded on the Variant Models.
    • Using a scope similar to the one above will be pretty nasty, as we will need to do a ->orWhereHas('variants.products.users' ... to allow the vendor to expose their own coupons even if they aren't currently active. This is a multi-nested SELECT * WHERE (EXISTS... that seems silly to have in place when <.1% of the requests will actually be from the vendor. It'll be mainly guests/users making such a request, which means we're really only concerned with the date constraint most of the time.
    • Shoot - we can't even apply such a global scope on the Coupon Adapter, as this adapter won't be initialized during this request. The relationship is loaded directly on the Variant Model and passed to the encoder. Now we'd have to apply this global scope directly on the Coupon Model's booted() method, which means we have to keep this in mind everywhere in our app. All of those cron jobs that iterate through products to do various things - yup, need to handle the global scopes.
    • I suppose we could go banana's on our http requests and do something like GET ...api/variants/{record}/coupons foreach variant. However, if a product has 25 variants, that's less than ideal.
    • We can't choose to simply remove the admin Gate check and ->orWhereHas('owning-user...' from the scopes, as that will leave us in a position where admins and vendors can't reveal the products that don't fall under the "normal user" exposure requirements.
    • Handling this with scopes seems to be one of the better options, as we're working with multiple hasMany relations. Without eager loading and asking the database to figure this stuff out will lead to serious N+1 problems.

I hope this is a clear enough example to illustrate the situation.

To cover a few other considerations I've had:

In conclusion, I'm hoping someone can shed some light on how they solve this dilemma. I feel like this would be a pretty common challenge to face, as lots of apps have entity relationships where anyone is "authorized" to ask for the records, but your response will limit which records are returned based on the user's exposure permissions.

lindyhopchris commented 4 years ago

Hi. This isn't really an issue for this package... the exact same issue would apply if you were using Blade templates to render pages that listed e.g. products or coupons. The question is effectively how to efficiently query the database based on access rights.

One thing we do is use HTTP middleware to apply global scopes to model classes. I.e. a model global scope does not need only to be applied via a model's boot method. As you mention, using the boot method means you have to then worry about it everywhere else in the app. So we've found that model scopes applied via HTTP middleware is an effective way of scoping access to models only for specific HTTP requests. Just use Model::addGlobalScope() within the middleware.

That middleware would not have to apply any scopes if the user is an admin. (Presuably that's fairly easy to check in your app's logic).

Then if they are not an admin, it'd need to apply 3 scopes - one scope to each model class. Then each of those scopes would have to limit the queries for the respective models, presumably using Eloquent relationship existence methods (e.g. whereHas()) to limit the queries using relationships. The logic for the query is described in the bullet points for each class that you mention at the start of your issue.

Hopefully that helps - let me know how you get on!

steven-backerclub commented 4 years ago

Thank you for the reply, @lindyhopchris. I do recognize this wasn't an issue for the package, but was simply curious for any pointers due to the "flexibility" the package/JSON API can provide the client while still being constrained to a limited set of endpoints.

Glad to hear the model-level Global Scope is a sensible avenue to pursue, with the option of applying them selectively via route middleware. We'll rock and roll with that concept.

Cheers.