Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
ResourceOwnershipMiddleware
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
3 / 3
8
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 forbiddenResponse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Middleware;
6
7use Nyholm\Psr7\Response;
8use Psr\Http\Message\ResponseInterface;
9use Psr\Http\Message\ServerRequestInterface;
10use Psr\Http\Server\MiddlewareInterface;
11use Psr\Http\Server\RequestHandlerInterface;
12use Slim\Routing\RouteContext;
13
14use function is_int;
15use function is_string;
16use function json_encode;
17
18/**
19 * Resource-ownership gate. Allows the request through only when the caller
20 * owns the targeted resource — i.e. a route argument matches the caller's own
21 * identity attribute set by JwtAuthMiddleware. Admins and super-admins bypass
22 * the check.
23 *
24 * Must run AFTER JwtAuthMiddleware (which populates `isAdmin` and the identity
25 * attributes) and after routing (so the route arguments are resolved).
26 *
27 * Example: `new ResourceOwnershipMiddleware('id', 'investorId')` lets an
28 * investor act on /investors/{id} only when {id} is their own investor id.
29 */
30final class ResourceOwnershipMiddleware implements MiddlewareInterface
31{
32    public function __construct(
33        private readonly string $routeArgument,
34        private readonly string $identityAttribute,
35    ) {}
36
37    public function process(
38        ServerRequestInterface $request,
39        RequestHandlerInterface $handler,
40    ): ResponseInterface {
41        // Admins and super-admins operate across all investors.
42        if ($request->getAttribute('isAdmin') === true) {
43            return $handler->handle($request);
44        }
45
46        $route = RouteContext::fromRequest($request)->getRoute();
47        $resourceId = $route?->getArgument($this->routeArgument);
48        $callerId = $request->getAttribute($this->identityAttribute);
49
50        // Both must be present; compare as strings since route args are strings
51        // while identity attributes are ints.
52        if (
53            $resourceId === null
54            || (!is_int($callerId) && !is_string($callerId))
55            || (string)$callerId !== $resourceId
56        ) {
57            return $this->forbiddenResponse();
58        }
59
60        return $handler->handle($request);
61    }
62
63    private function forbiddenResponse(): ResponseInterface
64    {
65        $response = new Response();
66        $response->getBody()->write((string)json_encode([
67            'success' => false,
68            'error' => 'Forbidden',
69            'message' => 'You do not have permission to access this resource',
70        ]));
71
72        return $response
73            ->withHeader('Content-Type', 'application/json')
74            ->withStatus(403);
75    }
76}