2

I know, this is a complex case but maybe one of you might have an idea on how to do this.

Concept

I have the following process in my API:

  1. Process query string parameters (FormRequest)
    • Replace key aliases by preferred keys
    • Map string parameters to arrays if an array ist expected
    • Set defaults (including Auth::user() for id-based parameters)
    • etc.
  2. Check if the user is allowed to do the request (Middleware)
    • Using processed (validated and sanitized) query params → otherwise I had to do exceptions for every possible alias and mapping as well as checking if the paramter is checked and that doesn't seem reasonable to me.

Problem

Nevertheless, if you just assign the middleware via ->middleware('middlewareName') to the route and the FormRequest via dependency injection to the controller method, first the middleware is called and after that the FormRequest. As described above, that's not what I need.

Solution approach

I first tried dependency injection at the middleware but it didn't work.

My solution was to assign the middleware in the controller constructor. Dependency injection works here, but suddenly Auth::user() returns null.

Then, I came across the FormRequest::createFrom($request) method in \Illuminate\Foundation\Providers\FormRequestServiceProvider.php:34 and the possibility to pass the $request object to the middleware's handle() method. The result looks like this:

public function __construct(Request $request)
{
    $middleware = new MyMiddleware();

    $request = MyRequest::createFrom($request);

    $middleware->handle($request, function() {})
}

But now the request is not validated yet. Just calling $request->validated() returns nothing. So I digged a little deeper and found that $resolved->validateResolved(); is done in \Illuminate\Foundation\Providers\FormRequestServiceProvider.php:30 but that doesn't seem to trigger the validation since it throws an exception saying that this method cannot be called on null but $request isn't null:

Call to a member function validated() on null

Now, I'm completely stumped. Does anyone know how to solve this or am I just doing it wrong?

Thanks in advance!

shaedrich
  • 5,457
  • 3
  • 26
  • 42
  • Checking if the user is allowed to do the request, is no optimal for middleware, more so for a policy there is called in the formrequest authorize() method. Wouldn't that help your problem? and why is it important for your middleware to have validated input, couldn't you just write it defensive? – mrhn Mar 19 '21 at 14:00
  • What exactly do you mean by defensive? Here's an example if I use the raw input data: `user_id` can be an integer or an array of integers, futhermore, `user_id` is also allowed to be passed as `user.id`. It would be annoying to have the same logic done both in the middleware and the FormRequest. So I'm going to look into the policies. Maybe they solve my problem. Thanks heretofore! – shaedrich Mar 19 '21 at 14:09
  • due to middlewares being so early in the lifecycle you would expect data to be bad, like if you wanted to check an user_id there, validate it is an int with intval(), where as later in your application you could assume it is int due to validation being triggered. – mrhn Mar 19 '21 at 14:16
  • I read the docs on policies and they seem to be required to be tied to a model which doesn't make sense in case of an API request which is more generic than to perform an action on a specific model. Is it possible to use a policy without linking it to a model? – shaedrich Mar 19 '21 at 14:20
  • But if you do rest api there is always an model in the context, but instead of being generic in your question be very specific? etc. crud api for an team that only an admin can update and delete – mrhn Mar 19 '21 at 19:35
  • It's neither a REST API nor a CRUD API per se. It's an endpoint that returns aggregated data. The aggregated data doesn't have a database table or view a hypothetical model could be associated with because it's aggregated on the fly. – shaedrich Mar 19 '21 at 21:17
  • I guess, I found my problem(s): While middleware is doing **authentication**, I was doing **authorization** there and therefore I now use a `Gate`. The aggregated data comes from multiple models so I would have to check them all separately. Since this is not a simple CRUD action, this seems a little cumbersome to me. I guess, `Gate` is what I'm gonna try and will probably work best in my case. Thanks for the hints! – shaedrich Mar 22 '21 at 09:26
  • i would really like to see the checks you are doing, because i would have a hard time believing it could not be done in a form request – mrhn Mar 22 '21 at 09:58
  • It's not that it _couldn't_ be done in a `FormRequest` but I believe that it's not meant to be handled by the `FormRequest` due to separation of concerns. When I'm done with the changes, I'll post the result as an answer. – shaedrich Mar 22 '21 at 10:20
  • But for another time it is very hard to come with suggestion, when the code in the question doesn't include what you are really doing as the check. I would argue that the form request would be the correct place, where middleware is more general terms. – mrhn Mar 22 '21 at 11:36
  • Maybe my answer illustrates that a bit better. – shaedrich Mar 22 '21 at 13:16

1 Answers1

0

I guess, I figured out a better way to do this.

My misconception

While middleware is doing authentication, I was doing authorization there and therefore I have to use a Gate

Resulting code

Controller

...
public function getData(MyRequest $request)
{
    $filters = $request->query();
    // execute queries
}
...

FormRequest

class MyRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return Gate::allows('get-data', $this);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            // ...
        ];
    }

    /**
     * Prepare the data for validation.
     *
     * @return void
     */
    protected function prepareForValidation()
    {
        $this->replace($this->cleanQueryParameters($this->query()));
    }

    private function cleanQueryParameters($queryParams): array
    {
        $queryParams = array_filter($queryParams, function($param) {
            return is_array($param) ? count($param) : strlen($param);
        });
        $defaultStartDate = (new \DateTime())->modify('monday next week');
        $defaultEndDate = (new \DateTime())->modify('friday next week');
        $defaults = [
            'article.created_by_id' => self::getDefaultEmployeeIds(),
            'date_from' => $defaultStartDate->format('Y-m-d'),
            'date_to' => $defaultEndDate->format('Y-m-d')
        ];
        $aliases = [
            // ...
        ];
        $mapper = [
            // ...
        ];
        foreach($aliases as $alias => $key) {
            if (array_key_exists($alias, $queryParams)) {
                $queryParams[$key] = $queryParams[$alias];
                unset($queryParams[$alias]);
            }
        }
        foreach($mapper as $key => $fn) {
            if (array_key_exists($key, $queryParams)) {
                $fn($queryParams, $key);
            }
        }
        $allowedFilters = array_merge(
            Ticket::$allowedApiParameters,
            array_map(function(string $param) {
                return 'article.'.$param;
            }, TicketArticle::$allowedApiParameters)
        );
        $arrayProps = [
            // ..
        ];
        foreach($queryParams as $param => $value) {
            if (!in_array($param, $allowedFilters) && !in_array($param, ['date_from', 'date_to'])) {
                abort(400, 'Filter "'.$param.'" not found');
            }
            if (in_array($param, $arrayProps)) {                
                $queryParams[$param] = guarantee('array', $value);
            }
        }
        return array_merge($defaults, $queryParams);
    }
}

Gate

class MyGate
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Auth\Access\Response|Void
     * @throws \Symfony\Component\HttpKernel\Exception\HttpException
     */
    public function authorizeGetDataCall(User $user, MyRequest $request): Response
    {
        Log::info('[MyGate] Checking permissions …');

        if (in_array(LDAPGroups::Admin, session('PermissionGroups', []))) {
            // no further checks needed
            Log::info('[MyGate] User is administrator. No further checks needed');
            return Response::allow();
        }

        if (
            ($request->has('group') && !in_array(Group::toLDAPGroup($request->get('group')), session('PermissionGroups', []))) ||
            $request->has('owner.department') && !in_array(Department::toLDAPGroup($request->query('owner.department')), session('PermissionGroups', [])) ||
            $request->has('creator.department') && !in_array(Department::toLDAPGroup($request->query('creator.department')), session('PermissionGroups', []))
        ) {
            Log::warning('[MyGate] Access denied due to insufficient group/deparment membership', [ 'group/department' =>
                $request->has('group') ?
                    Group::toLDAPGroup($request->get('group')) :
                ($request->has('owner.department') ?
                    Department::toLDAPGroup($request->query('owner.department')) :
                ($request->has('creator.department') ?
                    Department::toLDAPGroup($request->query('creator.department')) :
                null))
            ]);
            return Response::deny('Access denied');
        }
        if ($request->has('customer_id') || $request->has('article.created_by_id')) {
            $ids = [];
            if ($request->has('customer_id')) {
                $ids = array_merge($ids, $request->query('customer_id'));
            }
            if ($request->has('article.created_by_id')) {
                $ids = array_merge($ids, $request->query('article.created_by_id'));
            }
            $users = User::find($ids);
            $hasOtherLDAPGroup = !$users->every(function($user) {
                return in_array(Department::toLDAPGroup($user->department), session('PermissionGroups', []));
            });
            if ($hasOtherLDAPGroup) {
                Log::warning('[MyGate] Access denied due to insufficient permissions to see specific other user\'s data', [ 'ids' => $ids ]);
                return Response::deny('Access denied');;
            }
        }
        if ($request->has('owner.login') || $request->has('creator.login')) {
            $logins = [];
            if ($request->has('owner.login')) {
                $logins = array_merge(
                    $logins,
                    guarantee('array', $request->query('owner.login'))
                );
            }
            if ($request->has('creator.login')) {
                $logins = array_merge(
                    $logins,
                    guarantee('array', $request->query('creator.login'))
                );
            }
            $users = User::where([ 'samaccountname' => $logins ])->get();
            $hasOtherLDAPGroup = !$users->every(function($user) {
                return in_array(Department::toLDAPGroup($user->department), session('PermissionGroups', []));
            });
            if ($hasOtherLDAPGroup) {
                Log::warning('[MyGate] Access denied due to insufficient permissions to see specific other user\'s data', [ 'logins' => $logins ]);
                return Response::deny('Access denied');
            }
        }
        Log::info('[MyGate] Permission checks passed');
        return Response::allow();
    }
}

shaedrich
  • 5,457
  • 3
  • 26
  • 42