Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.63% covered (warning)
53.63%
170 / 317
48.15% covered (danger)
48.15%
13 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
PolicyController
53.63% covered (warning)
53.63%
170 / 317
48.15% covered (danger)
48.15%
13 / 27
897.72
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
 effective
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getSystem
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 getGroup
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 listGroupPolicies
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
8.51
 getUserPolicyForUser
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 listUserPolicies
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 setSystem
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 setGroup
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 setSystemCompound
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 setGroupCompound
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 setUserPreferenceCompound
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 setUserPolicyForUserCompound
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 clearGroup
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 setUserPreference
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 setUserPolicyForUser
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 clearUserPreference
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
2.29
 clearUserPolicyForUser
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 serializeGroupPolicy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 serializeGroupWritePolicy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 serializeUserPolicy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 readPolicyValueParam
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 readPolicyValueMapParam
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 readBoolMapParam
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 readBoolParam
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 forbiddenGroupPolicyResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 forbiddenUserPolicyResponse
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 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Controller;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Service\Policy\Model\PolicyLayer;
13use OCA\Libresign\Service\Policy\PolicyManagementScopeService;
14use OCA\Libresign\Service\Policy\PolicyService;
15use OCA\Libresign\Service\Policy\Provider\RequestSignGroups\RequestSignGroupsPolicyGuard;
16use OCP\AppFramework\Http;
17use OCP\AppFramework\Http\Attribute\ApiRoute;
18use OCP\AppFramework\Http\Attribute\NoAdminRequired;
19use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
20use OCP\AppFramework\Http\DataResponse;
21use OCP\IL10N;
22use OCP\IRequest;
23
24/**
25 * @psalm-import-type LibresignErrorResponse from \OCA\Libresign\ResponseDefinitions
26 * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions
27 * @psalm-import-type LibresignEffectivePoliciesResponse from \OCA\Libresign\ResponseDefinitions
28 * @psalm-import-type LibresignEffectiveCompoundPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
29 * @psalm-import-type LibresignGroupCompoundPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
30 * @psalm-import-type LibresignUserCompoundPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
31 * @psalm-import-type LibresignGroupPolicyResponse from \OCA\Libresign\ResponseDefinitions
32 * @psalm-import-type LibresignGroupPolicyState from \OCA\Libresign\ResponseDefinitions
33 * @psalm-import-type LibresignGroupPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
34 * @psalm-import-type LibresignSystemPolicyResponse from \OCA\Libresign\ResponseDefinitions
35 * @psalm-import-type LibresignSystemPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
36 * @psalm-import-type LibresignUserPolicyResponse from \OCA\Libresign\ResponseDefinitions
37 * @psalm-import-type LibresignUserPolicyState from \OCA\Libresign\ResponseDefinitions
38 * @psalm-import-type LibresignUserPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions
39 */
40final class PolicyController extends AEnvironmentAwareController {
41    public function __construct(
42        IRequest $request,
43        private IL10N $l10n,
44        private PolicyService $policyService,
45        private RequestSignGroupsPolicyGuard $requestSignGroupsPolicyGuard,
46        private PolicyManagementScopeService $policyManagementScopeService,
47    ) {
48        parent::__construct(Application::APP_ID, $request);
49    }
50
51    /**
52     * Effective policies bootstrap
53     *
54     * @return DataResponse<Http::STATUS_OK, LibresignEffectivePoliciesResponse, array{}>
55     *
56     * 200: OK
57     */
58    #[NoAdminRequired]
59    #[NoCSRFRequired]
60    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])]
61    public function effective(): DataResponse {
62        $ruleCounts = $this->policyManagementScopeService->resolveVisibleRuleCountsForCurrentActor();
63
64        /** @var array<string, LibresignEffectivePolicyState> $policies */
65        $policies = $this->policyService->resolveKnownPolicyStatesWithRuleCounts($ruleCounts);
66
67        /** @var LibresignEffectivePoliciesResponse $data */
68        $data = [
69            'policies' => $policies,
70        ];
71
72        return new DataResponse($data);
73    }
74
75    /**
76     * Read explicit system policy configuration
77     *
78     * @param string $policyKey Policy identifier to read from the system layer.
79     * @return DataResponse<Http::STATUS_OK, LibresignSystemPolicyResponse, array{}>
80     *
81     * 200: OK
82     */
83    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
84    public function getSystem(string $policyKey): DataResponse {
85        $policy = $this->policyService->getSystemPolicy($policyKey);
86
87        /** @var LibresignSystemPolicyResponse $data */
88        $data = [
89            'policy' => [
90                'policyKey' => $policyKey,
91                'scope' => ($policy?->getScope() === 'global' ? 'global' : 'system'),
92                'value' => $policy?->getValue(),
93                'allowChildOverride' => $policy?->isAllowChildOverride() ?? true,
94                'visibleToChild' => $policy?->isVisibleToChild() ?? true,
95                'allowedValues' => $policy?->getAllowedValues() ?? [],
96            ],
97        ];
98        $rawValue = $data['policy']['value'];
99        if (is_string($rawValue)) {
100            $decoded = json_decode($rawValue, true);
101            if (is_array($decoded)) {
102                $data['policy']['value'] = $decoded;
103            }
104        }
105
106        return new DataResponse($data);
107    }
108
109    /**
110     * Read a group-level policy value
111     *
112     * @param string $groupId Group identifier that receives the policy binding.
113     * @param string $policyKey Policy identifier to read for the selected group.
114     * @return DataResponse<Http::STATUS_OK, LibresignGroupPolicyResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
115     *
116     * 200: OK
117     * 403: Forbidden
118     */
119    #[NoAdminRequired]
120    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
121    public function getGroup(string $groupId, string $policyKey): DataResponse {
122        if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) {
123            return $this->forbiddenGroupPolicyResponse();
124        }
125
126        $policy = $this->policyService->getGroupPolicy($policyKey, $groupId);
127        if ($policy instanceof PolicyLayer && !$this->policyService->canViewGroupPolicy($policyKey, $groupId, $policy)) {
128            return $this->forbiddenGroupPolicyResponse();
129        }
130
131        /** @var LibresignGroupPolicyResponse $data */
132        $data = [
133            'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy),
134        ];
135
136        return new DataResponse($data);
137    }
138
139    /**
140     * List all explicit group-level policy values for a policy key
141     *
142     * @param string $policyKey Policy identifier to list group rules.
143     * @return DataResponse<Http::STATUS_OK, array{policies: list<LibresignGroupPolicyState>}, array{}>
144     *
145     * 200: OK
146     */
147    #[NoAdminRequired]
148    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/by-policy/group/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
149    public function listGroupPolicies(string $policyKey): DataResponse {
150        $manageableGroupIds = $this->policyManagementScopeService->resolveCurrentActorManageableGroupIdsForPolicy($policyKey);
151        if ($manageableGroupIds === null) {
152            $records = $this->policyService->listGroupPolicies($policyKey);
153        } elseif ($manageableGroupIds === []) {
154            $records = [];
155        } else {
156            $records = $this->policyService->listGroupPoliciesForTargets($policyKey, $manageableGroupIds);
157        }
158        $policies = [];
159
160        foreach ($records as $record) {
161            $groupId = (string)($record['targetId'] ?? '');
162            $policy = $record['policy'] ?? null;
163            if ($groupId === '' || !$policy instanceof PolicyLayer) {
164                continue;
165            }
166
167            if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) {
168                continue;
169            }
170
171            if (!$this->policyService->canViewGroupPolicy($policyKey, $groupId, $policy)) {
172                continue;
173            }
174
175            $policies[] = $this->serializeGroupPolicy($groupId, $policyKey, $policy);
176        }
177
178        return new DataResponse([
179            'policies' => $policies,
180        ]);
181    }
182
183    /**
184     * Read an explicit user-level policy for a target user (admin scope)
185     *
186     * @param string $userId Target user identifier that receives the policy assignment.
187     * @param string $policyKey Policy identifier to read for the selected user.
188     * @return DataResponse<Http::STATUS_OK, LibresignUserPolicyResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
189     *
190     * 200: OK
191     * 403: Forbidden
192     */
193    #[NoAdminRequired]
194    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
195    public function getUserPolicyForUser(string $userId, string $policyKey): DataResponse {
196        if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) {
197            return $this->forbiddenUserPolicyResponse();
198        }
199
200        $policy = $this->policyService->getUserPolicyForUserId($policyKey, $userId);
201
202        /** @var LibresignUserPolicyResponse $data */
203        $data = [
204            'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
205        ];
206
207        return new DataResponse($data);
208    }
209
210    /**
211     * List all explicit user-level policy values for a policy key
212     *
213     * @param string $policyKey Policy identifier to list user rules.
214     * @return DataResponse<Http::STATUS_OK, array{policies: list<LibresignUserPolicyState>}, array{}>
215     *
216     * 200: OK
217     */
218    #[NoAdminRequired]
219    #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/by-policy/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
220    public function listUserPolicies(string $policyKey): DataResponse {
221        $records = $this->policyService->listUserPolicies($policyKey);
222        $policies = [];
223
224        foreach ($records as $record) {
225            $userId = (string)($record['targetId'] ?? '');
226            $policy = $record['policy'] ?? null;
227            if ($userId === '' || !$policy instanceof PolicyLayer) {
228                continue;
229            }
230
231            if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) {
232                continue;
233            }
234
235            $policies[] = $this->serializeUserPolicy($userId, $policyKey, $policy);
236        }
237
238        return new DataResponse([
239            'policies' => $policies,
240        ]);
241    }
242
243    /**
244     * Save a system-level policy value
245     *
246     * @param string $policyKey Policy identifier to persist at the system layer.
247     * @param null|bool|int|float|string|array<string, mixed> $value Policy value to persist as an explicit system rule, even when it equals the default. Null removes the explicit system rule: the built-in default applies and lower layers may override it.
248     * @param bool $allowChildOverride Whether lower layers may override this system default.
249     * @return DataResponse<Http::STATUS_OK, LibresignSystemPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>
250     *
251     * 200: OK
252     * 400: Invalid policy value
253     */
254    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
255    public function setSystem(string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse {
256        $value = $this->readPolicyValueParam('value', $value);
257        $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride);
258
259        try {
260            $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, true);
261            $policy = $this->policyService->saveSystem($policyKey, $value, $allowChildOverride);
262            /** @var LibresignSystemPolicyWriteResponse $data */
263            $data = [
264                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
265                'message' => $this->l10n->t('Settings saved'),
266                'policy' => $policy->toArray(),
267            ];
268
269            return new DataResponse($data);
270        } catch (\InvalidArgumentException $exception) {
271            /** @var LibresignErrorResponse $data */
272            $data = [
273                'error' => $exception->getMessage(),
274            ];
275
276            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
277        }
278    }
279
280    /**
281     * Save a group-level policy value
282     *
283     * @param string $groupId Group identifier that receives the policy binding.
284     * @param string $policyKey Policy identifier to persist at the group layer.
285     * @param null|bool|int|float|string|array<string, mixed> $value Policy value to persist for the group.
286     * @param bool $allowChildOverride Whether users and requests below this group may override the group default.
287     * @return DataResponse<Http::STATUS_OK, LibresignGroupPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
288     *
289     * 200: OK
290     * 400: Invalid policy value
291     * 403: Forbidden
292     */
293    #[NoAdminRequired]
294    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
295    public function setGroup(string $groupId, string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse {
296        if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) {
297            return $this->forbiddenGroupPolicyResponse();
298        }
299
300        $value = $this->readPolicyValueParam('value', $value);
301        $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride);
302
303        try {
304            $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, false, $groupId);
305            $policy = $this->policyService->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride);
306            /** @var LibresignGroupPolicyWriteResponse $data */
307            $data = [
308                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
309                'message' => $this->l10n->t('Settings saved'),
310                'policy' => $this->serializeGroupWritePolicy($groupId, $policyKey, $policy),
311            ];
312
313            return new DataResponse($data);
314        } catch (\DomainException $exception) {
315            /** @var LibresignErrorResponse $data */
316            $data = [
317                'error' => $exception->getMessage(),
318            ];
319
320            return new DataResponse($data, Http::STATUS_FORBIDDEN);
321        } catch (\InvalidArgumentException $exception) {
322            /** @var LibresignErrorResponse $data */
323            $data = [
324                'error' => $exception->getMessage(),
325            ];
326
327            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
328        }
329    }
330
331    /**
332     * Save several system-level values of the same composite policy at once
333     *
334     * Settings that only make sense together are written as one operation: the
335     * policy they belong to validates the resulting configuration before any of
336     * them is stored, so the result does not depend on the order of the values.
337     *
338     * @param string $parentPolicyKey Policy identifier the other settings are grouped under.
339     * @param array<string, null|bool|int|float|string|array<string, mixed>> $values Values to persist, keyed by policy identifier, each as an explicit system rule even when it equals the default. Null removes the explicit system rule of that policy: the built-in default applies and lower layers may override it.
340     * @param array<string, bool> $allowChildOverride Whether lower layers may override each saved value, keyed by policy identifier.
341     * @return DataResponse<Http::STATUS_OK, LibresignEffectiveCompoundPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>
342     *
343     * 200: OK
344     * 400: Invalid policy value
345     */
346    #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/compound/system/{parentPolicyKey}', requirements: ['apiVersion' => '(v1)', 'parentPolicyKey' => '[a-z0-9_]+'])]
347    public function setSystemCompound(string $parentPolicyKey, array $values = [], array $allowChildOverride = []): DataResponse {
348        $values = $this->readPolicyValueMapParam('values', $values);
349        $allowChildOverride = $this->readBoolMapParam('allowChildOverride', $allowChildOverride);
350
351        try {
352            foreach ($values as $policyKey => $value) {
353                $values[$policyKey] = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, true);
354            }
355
356            $policies = [];
357            foreach ($this->policyService->saveSystemCompound($parentPolicyKey, $values, $allowChildOverride) as $policyKey => $policy) {
358                $policies[$policyKey] = $policy->toArray();
359            }
360
361            /** @var LibresignEffectiveCompoundPolicyWriteResponse $data */
362            $data = [
363                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
364                'message' => $this->l10n->t('Settings saved'),
365                'policies' => $policies,
366            ];
367
368            return new DataResponse($data);
369        } catch (\InvalidArgumentException $exception) {
370            /** @var LibresignErrorResponse $data */
371            $data = [
372                'error' => $exception->getMessage(),
373            ];
374
375            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
376        }
377    }
378
379    /**
380     * Save several group-level values of the same composite policy at once
381     *
382     * @param string $groupId Group identifier that receives the policy bindings.
383     * @param string $parentPolicyKey Policy identifier the other settings are grouped under.
384     * @param array<string, null|bool|int|float|string|array<string, mixed>> $values Values to persist for the group, keyed by policy identifier.
385     * @param array<string, bool> $allowChildOverride Whether users and requests below this group may override each saved value, keyed by policy identifier.
386     * @return DataResponse<Http::STATUS_OK, LibresignGroupCompoundPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
387     *
388     * 200: OK
389     * 400: Invalid policy value
390     * 403: Forbidden
391     */
392    #[NoAdminRequired]
393    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/compound/group/{groupId}/{parentPolicyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'parentPolicyKey' => '[a-z0-9_]+'])]
394    public function setGroupCompound(string $groupId, string $parentPolicyKey, array $values = [], array $allowChildOverride = []): DataResponse {
395        $values = $this->readPolicyValueMapParam('values', $values);
396        $allowChildOverride = $this->readBoolMapParam('allowChildOverride', $allowChildOverride);
397
398        foreach (array_keys($values) as $policyKey) {
399            if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) {
400                return $this->forbiddenGroupPolicyResponse();
401            }
402        }
403
404        try {
405            foreach ($values as $policyKey => $value) {
406                $values[$policyKey] = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, false, $groupId);
407            }
408
409            $policies = [];
410            foreach ($this->policyService->saveGroupPolicyCompound($parentPolicyKey, $groupId, $values, $allowChildOverride) as $policyKey => $policy) {
411                $policies[$policyKey] = $this->serializeGroupWritePolicy($groupId, $policyKey, $policy);
412            }
413
414            /** @var LibresignGroupCompoundPolicyWriteResponse $data */
415            $data = [
416                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
417                'message' => $this->l10n->t('Settings saved'),
418                'policies' => $policies,
419            ];
420
421            return new DataResponse($data);
422        } catch (\DomainException $exception) {
423            /** @var LibresignErrorResponse $data */
424            $data = [
425                'error' => $exception->getMessage(),
426            ];
427
428            return new DataResponse($data, Http::STATUS_FORBIDDEN);
429        } catch (\InvalidArgumentException $exception) {
430            /** @var LibresignErrorResponse $data */
431            $data = [
432                'error' => $exception->getMessage(),
433            ];
434
435            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
436        }
437    }
438
439    /**
440     * Save several values of the same composite policy as user preferences
441     *
442     * @param string $parentPolicyKey Policy identifier the other settings are grouped under.
443     * @param array<string, null|bool|int|float|string|array<string, mixed>> $values Values to persist as the current user's defaults, keyed by policy identifier.
444     * @return DataResponse<Http::STATUS_OK, LibresignEffectiveCompoundPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>
445     *
446     * 200: OK
447     * 400: Invalid policy value
448     */
449    #[NoAdminRequired]
450    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/compound/user/{parentPolicyKey}', requirements: ['apiVersion' => '(v1)', 'parentPolicyKey' => '[a-z0-9_]+'])]
451    public function setUserPreferenceCompound(string $parentPolicyKey, array $values = []): DataResponse {
452        $values = $this->readPolicyValueMapParam('values', $values);
453
454        try {
455            foreach (array_keys($values) as $policyKey) {
456                $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
457            }
458
459            $policies = [];
460            foreach ($this->policyService->saveUserPreferenceCompound($parentPolicyKey, $values) as $policyKey => $policy) {
461                $policies[$policyKey] = $policy->toArray();
462            }
463
464            /** @var LibresignEffectiveCompoundPolicyWriteResponse $data */
465            $data = [
466                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
467                'message' => $this->l10n->t('Settings saved'),
468                'policies' => $policies,
469            ];
470
471            return new DataResponse($data);
472        } catch (\InvalidArgumentException $exception) {
473            /** @var LibresignErrorResponse $data */
474            $data = [
475                'error' => $exception->getMessage(),
476            ];
477
478            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
479        }
480    }
481
482    /**
483     * Save several values of the same composite policy for a target user (admin scope)
484     *
485     * @param string $userId Target user identifier that receives the policy assignments.
486     * @param string $parentPolicyKey Policy identifier the other settings are grouped under.
487     * @param array<string, null|bool|int|float|string|array<string, mixed>> $values Values to persist for the target user, keyed by policy identifier.
488     * @param array<string, bool> $allowChildOverride Whether the target user may still override each assigned value, keyed by policy identifier.
489     * @return DataResponse<Http::STATUS_OK, LibresignUserCompoundPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
490     *
491     * 200: OK
492     * 400: Invalid policy value
493     * 403: Forbidden
494     */
495    #[NoAdminRequired]
496    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/compound/user/{userId}/{parentPolicyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'parentPolicyKey' => '[a-z0-9_]+'])]
497    public function setUserPolicyForUserCompound(string $userId, string $parentPolicyKey, array $values = [], array $allowChildOverride = []): DataResponse {
498        if (!$this->policyManagementScopeService->canCurrentActorManageUserPolicy($userId)) {
499            return $this->forbiddenUserPolicyResponse();
500        }
501
502        $values = $this->readPolicyValueMapParam('values', $values);
503        $allowChildOverride = $this->readBoolMapParam('allowChildOverride', $allowChildOverride);
504
505        foreach (array_keys($values) as $policyKey) {
506            if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) {
507                return $this->forbiddenUserPolicyResponse();
508            }
509        }
510
511        try {
512            foreach (array_keys($values) as $policyKey) {
513                $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
514            }
515
516            $policies = [];
517            foreach ($this->policyService->saveUserPolicyForUserIdCompound($parentPolicyKey, $userId, $values, $allowChildOverride) as $policyKey => $policy) {
518                $policies[$policyKey] = $this->serializeUserPolicy($userId, $policyKey, $policy);
519            }
520
521            /** @var LibresignUserCompoundPolicyWriteResponse $data */
522            $data = [
523                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
524                'message' => $this->l10n->t('Settings saved'),
525                'policies' => $policies,
526            ];
527
528            return new DataResponse($data);
529        } catch (\InvalidArgumentException $exception) {
530            /** @var LibresignErrorResponse $data */
531            $data = [
532                'error' => $exception->getMessage(),
533            ];
534
535            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
536        }
537    }
538
539    /**
540     * Clear a group-level policy value
541     *
542     * @param string $groupId Group identifier that receives the policy binding.
543     * @param string $policyKey Policy identifier to clear for the selected group.
544     * @return DataResponse<Http::STATUS_OK, LibresignGroupPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
545     *
546     * 200: OK
547     * 400: Invalid policy value
548     * 403: Forbidden
549     */
550    #[NoAdminRequired]
551    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
552    public function clearGroup(string $groupId, string $policyKey): DataResponse {
553        if (!$this->policyManagementScopeService->canCurrentActorManageGroupPolicy($groupId, $policyKey)) {
554            return $this->forbiddenGroupPolicyResponse();
555        }
556
557        try {
558            $policy = $this->policyService->clearGroupPolicy($policyKey, $groupId);
559            /** @var LibresignGroupPolicyWriteResponse $data */
560            $data = [
561                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
562                'message' => $this->l10n->t('Settings saved'),
563                'policy' => $this->serializeGroupWritePolicy($groupId, $policyKey, $policy),
564            ];
565
566            return new DataResponse($data);
567        } catch (\InvalidArgumentException $exception) {
568            /** @var LibresignErrorResponse $data */
569            $data = [
570                'error' => $exception->getMessage(),
571            ];
572
573            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
574        } catch (\DomainException $exception) {
575            /** @var LibresignErrorResponse $data */
576            $data = [
577                'error' => $exception->getMessage(),
578            ];
579
580            return new DataResponse($data, Http::STATUS_FORBIDDEN);
581        }
582    }
583
584    /**
585     * Save a user policy preference
586     *
587     * @param string $policyKey Policy identifier to persist for the current user.
588     * @param null|bool|int|float|string|array<string, mixed> $value Policy value to persist as the current user's default.
589     * @return DataResponse<Http::STATUS_OK, LibresignSystemPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>
590     *
591     * 200: OK
592     * 400: Invalid policy value
593     */
594    #[NoAdminRequired]
595    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
596    public function setUserPreference(string $policyKey, null|bool|int|float|string|array $value = null): DataResponse {
597        $value = $this->readPolicyValueParam('value', $value);
598
599        try {
600            $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
601            $policy = $this->policyService->saveUserPreference($policyKey, $value);
602            /** @var LibresignSystemPolicyWriteResponse $data */
603            $data = [
604                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
605                'message' => $this->l10n->t('Settings saved'),
606                'policy' => $policy->toArray(),
607            ];
608
609            return new DataResponse($data);
610        } catch (\InvalidArgumentException $exception) {
611            /** @var LibresignErrorResponse $data */
612            $data = [
613                'error' => $exception->getMessage(),
614            ];
615
616            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
617        }
618    }
619
620    /**
621     * Save an explicit user policy for a target user (admin scope)
622     *
623     * @param string $userId Target user identifier that receives the policy assignment.
624     * @param string $policyKey Policy identifier to persist for the target user.
625     * @param null|bool|int|float|string|array<string, mixed> $value Policy value to persist as assigned target user policy.
626     * @param bool $allowChildOverride Whether the target user may still override the assigned value in personal preferences.
627     * @return DataResponse<Http::STATUS_OK, LibresignUserPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
628     *
629     * 200: OK
630     * 400: Invalid policy value
631     * 403: Forbidden
632     */
633    #[NoAdminRequired]
634    #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
635    public function setUserPolicyForUser(string $userId, string $policyKey, null|bool|int|float|string|array $value = null, bool $allowChildOverride = false): DataResponse {
636        if (!$this->policyManagementScopeService->canCurrentActorManageUserPolicy($userId)) {
637            return $this->forbiddenUserPolicyResponse();
638        }
639
640        $value = $this->readPolicyValueParam('value', $value);
641        $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride);
642
643        try {
644            $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
645            if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) {
646                return $this->forbiddenUserPolicyResponse();
647            }
648            $policy = $this->policyService->saveUserPolicyForUserId($policyKey, $userId, $value, $allowChildOverride);
649            /** @var LibresignUserPolicyWriteResponse $data */
650            $data = [
651                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
652                'message' => $this->l10n->t('Settings saved'),
653                'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
654            ];
655
656            return new DataResponse($data);
657        } catch (\InvalidArgumentException $exception) {
658            /** @var LibresignErrorResponse $data */
659            $data = [
660                'error' => $exception->getMessage(),
661            ];
662
663            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
664        }
665    }
666
667    /**
668     * Clear a user policy preference
669     *
670     * @param string $policyKey Policy identifier to clear for the current user.
671     * @return DataResponse<Http::STATUS_OK, LibresignSystemPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>
672     *
673     * 200: OK
674     * 400: User-scope not supported
675     */
676    #[NoAdminRequired]
677    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
678    public function clearUserPreference(string $policyKey): DataResponse {
679        try {
680            $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
681            $policy = $this->policyService->clearUserPreference($policyKey);
682            /** @var LibresignSystemPolicyWriteResponse $data */
683            $data = [
684                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
685                'message' => $this->l10n->t('Settings saved'),
686                'policy' => $policy->toArray(),
687            ];
688
689            return new DataResponse($data);
690        } catch (\InvalidArgumentException $exception) {
691            /** @var LibresignErrorResponse $data */
692            $data = [
693                'error' => $exception->getMessage(),
694            ];
695
696            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
697        }
698    }
699
700    /**
701     * Clear an explicit user policy for a target user (admin scope)
702     *
703     * @param string $userId Target user identifier that receives the policy assignment removal.
704     * @param string $policyKey Policy identifier to clear for the target user.
705     * @return DataResponse<Http::STATUS_OK, LibresignUserPolicyWriteResponse, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, LibresignErrorResponse, array{}>|DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}>
706     *
707     * 200: OK
708     * 400: User-scope not supported
709     * 403: Forbidden
710     */
711    #[NoAdminRequired]
712    #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
713    public function clearUserPolicyForUser(string $userId, string $policyKey): DataResponse {
714        if (!$this->policyManagementScopeService->canCurrentActorManageUserPolicy($userId)) {
715            return $this->forbiddenUserPolicyResponse();
716        }
717
718        try {
719            $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey);
720            if (!$this->policyManagementScopeService->canCurrentActorManageScopedUserPolicy($userId, $policyKey)) {
721                return $this->forbiddenUserPolicyResponse();
722            }
723            $policy = $this->policyService->clearUserPolicyForUserId($policyKey, $userId);
724            /** @var LibresignUserPolicyWriteResponse $data */
725            $data = [
726                // TRANSLATORS Success message shown after saving LibreSign policy settings in the Policy Workbench.
727                'message' => $this->l10n->t('Settings saved'),
728                'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
729            ];
730
731            return new DataResponse($data);
732        } catch (\InvalidArgumentException $exception) {
733            /** @var LibresignErrorResponse $data */
734            $data = [
735                'error' => $exception->getMessage(),
736            ];
737
738            return new DataResponse($data, Http::STATUS_BAD_REQUEST);
739        }
740    }
741
742    /** @return LibresignGroupPolicyState */
743    private function serializeGroupPolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array {
744        return [
745            'policyKey' => $policyKey,
746            'scope' => 'group',
747            'targetId' => $groupId,
748            'value' => $policy?->getValue(),
749            'allowChildOverride' => $policy?->isAllowChildOverride() ?? true,
750            'visibleToChild' => $policy?->isVisibleToChild() ?? true,
751            'allowedValues' => $policy?->getAllowedValues() ?? [],
752            'deletableByCurrentActor' => $this->policyService->canDeleteGroupPolicy($policyKey, $groupId, $policy),
753        ];
754    }
755
756    /** @return LibresignGroupPolicyState */
757    private function serializeGroupWritePolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array {
758        $serialized = $this->serializeGroupPolicy($groupId, $policyKey, $policy);
759        $serialized['effectiveValue'] = $policy?->getValue();
760
761        return $serialized;
762    }
763
764    /** @return LibresignUserPolicyState */
765    private function serializeUserPolicy(string $userId, string $policyKey, ?PolicyLayer $policy): array {
766        return [
767            'policyKey' => $policyKey,
768            'scope' => 'user_policy',
769            'targetId' => $userId,
770            'value' => $policy?->getValue(),
771            'allowChildOverride' => $policy?->isAllowChildOverride() ?? true,
772        ];
773    }
774
775    private function readPolicyValueParam(string $key, null|bool|int|float|string|array $default): null|bool|int|float|string|array {
776        $value = $this->request->getParams()[$key] ?? $default;
777        if (!is_scalar($value) && !is_array($value) && $value !== null) {
778            return $default;
779        }
780
781        return $value;
782    }
783
784    /**
785     * @param array<string, mixed> $default
786     * @return array<string, mixed>
787     */
788    private function readPolicyValueMapParam(string $key, array $default): array {
789        $values = $this->request->getParams()[$key] ?? $default;
790        if (!is_array($values)) {
791            return $default;
792        }
793
794        $policyValues = [];
795        foreach ($values as $policyKey => $value) {
796            if (!is_scalar($value) && !is_array($value) && $value !== null) {
797                continue;
798            }
799
800            $policyValues[(string)$policyKey] = $value;
801        }
802
803        return $policyValues;
804    }
805
806    /**
807     * @param array<string, mixed> $default
808     * @return array<string, bool>
809     */
810    private function readBoolMapParam(string $key, array $default): array {
811        $values = $this->request->getParams()[$key] ?? $default;
812        if (!is_array($values)) {
813            return [];
814        }
815
816        $flags = [];
817        foreach ($values as $policyKey => $value) {
818            if (!is_bool($value)) {
819                continue;
820            }
821
822            $flags[(string)$policyKey] = $value;
823        }
824
825        return $flags;
826    }
827
828    private function readBoolParam(string $key, bool $default): bool {
829        $value = $this->request->getParams()[$key] ?? $default;
830        return is_bool($value) ? $value : $default;
831    }
832
833    /** @return DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}> */
834    private function forbiddenGroupPolicyResponse(): DataResponse {
835        /** @var LibresignErrorResponse $data */
836        $data = [
837            // TRANSLATORS Permission error shown when a non-admin tries to manage policy settings scoped to a Nextcloud group.
838            'error' => $this->l10n->t('Not allowed to manage this group policy'),
839        ];
840
841        return new DataResponse($data, Http::STATUS_FORBIDDEN);
842    }
843
844    /** @return DataResponse<Http::STATUS_FORBIDDEN, LibresignErrorResponse, array{}> */
845    private function forbiddenUserPolicyResponse(): DataResponse {
846        /** @var LibresignErrorResponse $data */
847        $data = [
848            // TRANSLATORS Permission error shown when a non-admin tries to manage policy settings scoped to another user.
849            'error' => $this->l10n->t('Not allowed to manage this user policy'),
850        ];
851
852        return new DataResponse($data, Http::STATUS_FORBIDDEN);
853    }
854}