Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.80% covered (warning)
82.80%
77 / 93
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProfilePermissionApiController
82.61% covered (warning)
82.61%
76 / 92
45.45% covered (danger)
45.45%
5 / 11
42.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 matrix
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 validateUserAccess
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 userProfileMatrix
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 saveMatrix
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 updateModule
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 bulkUpdateModule
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 updateField
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 bulkUpdateField
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 parseJsonBody
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 jsonResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Profiles\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Security\PermissionContextFactory;
12use App\Modules\Profiles\Application\Service\ProfilePermissionServiceInterface;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16
17/**
18 * Profile Permission REST API Controller.
19 *
20 * Exposes endpoints for managing functional and field-level permissions of profiles.
21 *
22 * @package App\Modules\Profiles\Presentation\Api
23 */
24final readonly class ProfilePermissionApiController
25{
26    private const string CONTENT_TYPE = 'Content-Type';
27    private const string APP_JSON     = 'application/json';
28    private const string ERR_SUPERUSER_REQUIRED = 'Superuser privileges required';
29
30    /**
31     * ProfilePermissionApiController constructor.
32     *
33     * @param ProfilePermissionServiceInterface $service         Profile permission service.
34     * @param PermissionContextFactory          $contextFactory  Security context factory.
35     * @param ResponseFactoryInterface          $responseFactory PSR-7 response factory.
36     */
37    public function __construct(
38        private ProfilePermissionServiceInterface $service,
39        private PermissionContextFactory          $contextFactory,
40        private ResponseFactoryInterface          $responseFactory
41    ) {
42    }
43
44    /**
45     * Returns full module & field permissions matrix for a profile.
46     *
47     * @param ServerRequestInterface $request   Incoming HTTP request.
48     * @param int                    $profileId Target profile ID.
49     * @return ResponseInterface JSON response.
50     */
51    public function matrix(ServerRequestInterface $request, int $profileId): ResponseInterface
52    {
53        $context = $this->contextFactory->createFromRequest($request);
54        if (!$context->isAuthenticated()) {
55            return $this->jsonResponse(['error' => 'Authentication required'], 401);
56        }
57
58        $matrix = $this->service->buildPermissionMatrix($profileId);
59
60        return $this->jsonResponse([
61            'success' => true,
62            'data'    => $matrix,
63        ]);
64    }
65
66    private function validateUserAccess(ServerRequestInterface $request, int $userId): ?ResponseInterface
67    {
68        if ($userId <= 0) {
69            return $this->jsonResponse([
70                'success' => false,
71                'error'   => 'Invalid user identifier.',
72            ], 400);
73        }
74
75        $context = $this->contextFactory->createFromRequest($request);
76        if (!$context->isAuthenticated()) {
77            return $this->jsonResponse(['error' => 'Authentication required'], 401);
78        }
79
80        return (!$context->isSuperuser && $context->actorUserId !== $userId)
81            ? $this->jsonResponse(['success' => false, 'error' => 'Access denied.'], 403)
82            : null;
83    }
84
85    /**
86     * Returns full module & field permissions matrix for a specific user.
87     *
88     * @param ServerRequestInterface $request Incoming HTTP request.
89     * @param int                    $userId  Target user ID.
90     * @return ResponseInterface JSON response.
91     */
92    public function userProfileMatrix(ServerRequestInterface $request, int $userId): ResponseInterface
93    {
94        $error = $this->validateUserAccess($request, $userId);
95        if ($error !== null) {
96            return $error;
97        }
98
99        $matrix = $this->service->buildUserPermissionMatrix($userId);
100
101        return $this->jsonResponse([
102            'success' => true,
103            'data'    => $matrix,
104        ]);
105    }
106
107    /**
108     * Saves full permissions matrix (modules and fields) in one atomic transaction.
109     *
110     * @param ServerRequestInterface $request   Incoming HTTP request with JSON body.
111     * @param int                    $profileId Target profile ID.
112     * @return ResponseInterface JSON response.
113     */
114    public function saveMatrix(ServerRequestInterface $request, int $profileId): ResponseInterface
115    {
116        $context = $this->contextFactory->createFromRequest($request);
117        if (!$context->isAuthenticated() || !$context->isSuperuser) {
118            return $this->jsonResponse(['error' => self::ERR_SUPERUSER_REQUIRED], 403);
119        }
120
121        $data = $this->parseJsonBody($request);
122        /** @var list<array<string, mixed>> $modules */
123        $modules = is_array($data['modules'] ?? null) ? $data['modules'] : [];
124        /** @var list<array<string, mixed>> $fields */
125        $fields = is_array($data['fields'] ?? null) ? $data['fields'] : [];
126
127        $this->service->savePermissionMatrix($profileId, $modules, $fields);
128
129        return $this->jsonResponse(['success' => true]);
130    }
131
132    /**
133     * Updates module-level permissions for a specific module in a profile.
134     *
135     * @param ServerRequestInterface $request   Incoming HTTP request with JSON body.
136     * @param int                    $profileId Target profile ID.
137     * @return ResponseInterface JSON response.
138     */
139    public function updateModule(ServerRequestInterface $request, int $profileId): ResponseInterface
140    {
141        $context = $this->contextFactory->createFromRequest($request);
142        if (!$context->isAuthenticated() || !$context->isSuperuser) {
143            return $this->jsonResponse(['error' => self::ERR_SUPERUSER_REQUIRED], 403);
144        }
145
146        $data = $this->parseJsonBody($request);
147        $moduleName = (string) ($data['module_name'] ?? '');
148        if ($moduleName === '') {
149            return $this->jsonResponse(['error' => 'Missing module_name'], 400);
150        }
151
152        $this->service->updateModulePermission(
153            profileId:  $profileId,
154            moduleName: $moduleName,
155            canView:    (bool) ($data['can_view'] ?? true),
156            canCreate:  (bool) ($data['can_create'] ?? true),
157            canEdit:    (bool) ($data['can_edit'] ?? true),
158            canDelete:  (bool) ($data['can_delete'] ?? true),
159        );
160
161        return $this->jsonResponse(['success' => true]);
162    }
163
164    /**
165     * Bulk updates a specific action across all modules for a profile.
166     *
167     * @param ServerRequestInterface $request   Incoming HTTP request with JSON body.
168     * @param int                    $profileId Target profile ID.
169     * @return ResponseInterface JSON response.
170     */
171    public function bulkUpdateModule(ServerRequestInterface $request, int $profileId): ResponseInterface
172    {
173        $context = $this->contextFactory->createFromRequest($request);
174        if (!$context->isAuthenticated() || !$context->isSuperuser) {
175            return $this->jsonResponse(['error' => self::ERR_SUPERUSER_REQUIRED], 403);
176        }
177
178        $data = $this->parseJsonBody($request);
179        $action = (string) ($data['action'] ?? '');
180        $value = (bool) ($data['value'] ?? true);
181
182        if (!in_array($action, ['view', 'create', 'edit', 'delete'], true)) {
183            return $this->jsonResponse(['error' => 'Invalid action type'], 400);
184        }
185
186        $this->service->bulkUpdateModulePermission($profileId, $action, $value);
187
188        return $this->jsonResponse(['success' => true]);
189    }
190
191    /**
192     * Updates field-level security permission for a single field in a profile.
193     *
194     * @param ServerRequestInterface $request   Incoming HTTP request with JSON body.
195     * @param int                    $profileId Target profile ID.
196     * @return ResponseInterface JSON response.
197     */
198    public function updateField(ServerRequestInterface $request, int $profileId): ResponseInterface
199    {
200        $context = $this->contextFactory->createFromRequest($request);
201        if (!$context->isAuthenticated() || !$context->isSuperuser) {
202            return $this->jsonResponse(['error' => self::ERR_SUPERUSER_REQUIRED], 403);
203        }
204
205        $data = $this->parseJsonBody($request);
206        $moduleName = (string) ($data['module_name'] ?? '');
207        $fieldKey = (string) ($data['field_key'] ?? '');
208        $permission = (string) ($data['permission'] ?? 'edit');
209
210        if ($moduleName === '' || $fieldKey === '') {
211            return $this->jsonResponse(['error' => 'Missing module_name or field_key'], 400);
212        }
213
214        $this->service->updateFieldPermission($profileId, $moduleName, $fieldKey, $permission);
215
216        return $this->jsonResponse(['success' => true]);
217    }
218
219    /**
220     * Bulk updates all fields of a module to the specified permission level.
221     *
222     * @param ServerRequestInterface $request   Incoming HTTP request with JSON body.
223     * @param int                    $profileId Target profile ID.
224     * @return ResponseInterface JSON response.
225     */
226    public function bulkUpdateField(ServerRequestInterface $request, int $profileId): ResponseInterface
227    {
228        $context = $this->contextFactory->createFromRequest($request);
229        if (!$context->isAuthenticated() || !$context->isSuperuser) {
230            return $this->jsonResponse(['error' => self::ERR_SUPERUSER_REQUIRED], 403);
231        }
232
233        $data = $this->parseJsonBody($request);
234        $moduleName = (string) ($data['module_name'] ?? '');
235        $permission = (string) ($data['permission'] ?? 'edit');
236
237        if ($moduleName === '') {
238            return $this->jsonResponse(['error' => 'Missing module_name'], 400);
239        }
240
241        $this->service->bulkUpdateFieldPermissions($profileId, $moduleName, $permission);
242
243        return $this->jsonResponse(['success' => true]);
244    }
245
246    /**
247     * Parses request JSON body payload into associative array.
248     *
249     * @param ServerRequestInterface $request Incoming request.
250     * @return array<string, mixed> Parsed data.
251     */
252    private function parseJsonBody(ServerRequestInterface $request): array
253    {
254        $body = (string) $request->getBody();
255        if ($body === '') {
256            return [];
257        }
258
259        $decoded = json_decode($body, true);
260
261        return is_array($decoded) ? $decoded : [];
262    }
263
264    /**
265     * Builds standard JSON HTTP response.
266     *
267     * @param array<string, mixed> $data   Response data.
268     * @param int                  $status HTTP status code.
269     * @return ResponseInterface PSR-7 response.
270     */
271    private function jsonResponse(array $data, int $status = 200): ResponseInterface
272    {
273        $response = $this->responseFactory->createResponse($status)
274            ->withHeader(self::CONTENT_TYPE, self::APP_JSON);
275        $response->getBody()->write((string) json_encode($data, JSON_UNESCAPED_UNICODE));
276
277        return $response;
278    }
279}