Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.33% covered (warning)
78.33%
253 / 323
64.71% covered (warning)
64.71%
33 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
PolicyService
78.33% covered (warning)
78.33%
253 / 323
64.71% covered (warning)
64.71%
33 / 51
207.90
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
 resolve
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveForUserId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveForUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveForUserIdWithStoredRequestOverrides
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 resolveKnownPolicies
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 resolveKnownPoliciesForUserId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resolveKnownPolicyStates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveKnownPolicyStatesForUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveKnownPolicyStatesForUserIdWithoutUserScope
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 resolveKnownPolicyStatesWithRuleCounts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRequestLifecycle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSystemPolicy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserPolicyForUserId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 listUserPolicies
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 saveSystem
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 clearSystem
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getGroupPolicy
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 listGroupPolicies
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 listGroupPoliciesForTargets
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 countVisibleGroupPoliciesForTargets
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 getCountedPolicyKeys
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 saveGroupPolicy
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 clearGroupPolicy
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 canDeleteGroupPolicy
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 canViewGroupPolicy
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 shouldFilterVisibleGroupCountsForCurrentActor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 canManageUserPolicyForUserId
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 assertCurrentActorCanDeleteGroupPolicy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 assertCurrentActorCanEditGroupPolicy
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
5.05
 wasGroupPolicyCreatedBySystemAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldHideSystemCreatedGroupRuleFromCurrentActor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 assertCurrentActorCanManageGroupPolicy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 canCurrentActorManageGroupPolicy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 assertScopeSupported
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
 saveUserPreference
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
2.02
 clearUserPreference
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
1.06
 saveUserPolicyForUserId
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 clearUserPolicyForUserId
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
1.06
 saveSystemCompound
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 saveGroupPolicyCompound
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 saveUserPreferenceCompound
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 saveUserPolicyForUserIdCompound
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 validateCompositeWrite
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 resolveCompositeFamily
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 normalizeCompositeValues
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 inTransaction
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getRuleCounts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllRuleCounts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeResolvedPolicies
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 resolveKnownPoliciesForContext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
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\Service\Policy;
10
11use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
12use OCA\Libresign\Service\Policy\Model\PolicyContext;
13use OCA\Libresign\Service\Policy\Model\PolicyLayer;
14use OCA\Libresign\Service\Policy\Model\PolicySpec;
15use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
16use OCA\Libresign\Service\Policy\Runtime\DefaultPolicyResolver;
17use OCA\Libresign\Service\Policy\Runtime\PolicyContextFactory;
18use OCA\Libresign\Service\Policy\Runtime\PolicyRegistry;
19use OCA\Libresign\Service\Policy\Runtime\PolicySource;
20use OCP\IDBConnection;
21use OCP\IL10N;
22use OCP\IUser;
23
24class PolicyService {
25    private DefaultPolicyResolver $resolver;
26
27    public function __construct(
28        private PolicyContextFactory $contextFactory,
29        private PolicySource $source,
30        private PolicyRegistry $registry,
31        private IL10N $l10n,
32        private IDBConnection $db,
33    ) {
34        $this->resolver = new DefaultPolicyResolver($this->source);
35    }
36
37    /** @param array<string, mixed> $requestOverrides */
38    public function resolve(string|\BackedEnum $policyKey, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
39        return $this->resolver->resolve(
40            $this->registry->get($policyKey),
41            $this->contextFactory->forCurrentUser($requestOverrides, $activeContext),
42        );
43    }
44
45    /** @param array<string, mixed> $requestOverrides */
46    public function resolveForUserId(string|\BackedEnum $policyKey, ?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
47        return $this->resolver->resolve(
48            $this->registry->get($policyKey),
49            $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext),
50        );
51    }
52
53    /** @param array<string, mixed> $requestOverrides */
54    public function resolveForUser(string|\BackedEnum $policyKey, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
55        return $this->resolver->resolve(
56            $this->registry->get($policyKey),
57            $this->contextFactory->forUser($user, $requestOverrides, $activeContext),
58        );
59    }
60
61    /**
62     * Replays request values a document stored earlier. They were authorized
63     * when they were submitted, so they do not depend on who resolves them now.
64     *
65     * @param array<string, mixed> $storedRequestOverrides
66     */
67    public function resolveForUserIdWithStoredRequestOverrides(string|\BackedEnum $policyKey, ?string $userId, array $storedRequestOverrides, ?array $activeContext = null): ResolvedPolicy {
68        return $this->resolver->resolve(
69            $this->registry->get($policyKey),
70            $this->contextFactory->forUserId($userId, [], $activeContext)->setStoredRequestOverrides($storedRequestOverrides),
71        );
72    }
73
74    /** @return array<string, ResolvedPolicy> */
75    public function resolveKnownPolicies(array $requestOverrides = [], ?array $activeContext = null): array {
76        return $this->resolveKnownPoliciesForContext(
77            $this->contextFactory->forCurrentUser($requestOverrides, $activeContext),
78        );
79    }
80
81    /** @return array<string, ResolvedPolicy> */
82    public function resolveKnownPoliciesForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array {
83        return $this->resolveKnownPoliciesForContext(
84            $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext),
85        );
86    }
87
88    /** @return array<string, array<string, mixed>> */
89    public function resolveKnownPolicyStates(array $requestOverrides = [], ?array $activeContext = null): array {
90        return $this->serializeResolvedPolicies($this->resolveKnownPolicies($requestOverrides, $activeContext));
91    }
92
93    /** @return array<string, array<string, mixed>> */
94    public function resolveKnownPolicyStatesForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array {
95        return $this->serializeResolvedPolicies($this->resolveKnownPoliciesForUserId($userId, $requestOverrides, $activeContext));
96    }
97
98    /**
99     * Resolve requester-facing policy states using the target user's group membership,
100     * while intentionally ignoring user-specific layers such as assigned user policies
101     * and personal preferences.
102     *
103     * This is used by public validation pages, which should reflect the requester's
104     * inherited group/system policy posture without exposing user-scoped overrides.
105     *
106     * @return array<string, array<string, mixed>>
107     */
108    public function resolveKnownPolicyStatesForUserIdWithoutUserScope(?string $userId, array $requestOverrides = [], ?array $activeContext = null): array {
109        $context = $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext);
110        $context->setUserId(null);
111
112        return $this->serializeResolvedPolicies(
113            $this->resolveKnownPoliciesForContext($context),
114        );
115    }
116
117    /**
118     * @param array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $ruleCounts
119     * @return array<string, array<string, mixed>>
120     */
121    public function resolveKnownPolicyStatesWithRuleCounts(array $ruleCounts, array $requestOverrides = [], ?array $activeContext = null): array {
122        return $this->serializeResolvedPolicies(
123            $this->resolveKnownPolicies($requestOverrides, $activeContext),
124            $ruleCounts,
125        );
126    }
127
128    public function getRequestLifecycle(string|\BackedEnum $policyKey): string {
129        return $this->registry->get($policyKey)->requestLifecycle();
130    }
131
132    public function getSystemPolicy(string|\BackedEnum $policyKey): ?PolicyLayer {
133        $definition = $this->registry->get($policyKey);
134        return $this->source->loadSystemPolicy($definition->key());
135    }
136
137    public function getUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer {
138        $definition = $this->registry->get($policyKey);
139        if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) {
140            return null;
141        }
142
143        return $this->source->loadUserPolicyConfig($definition->key(), $userId);
144    }
145
146    /**
147     * @return list<array{targetId: string, policy: PolicyLayer}>
148     */
149    public function listUserPolicies(string|\BackedEnum $policyKey): array {
150        $definition = $this->registry->get($policyKey);
151        if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) {
152            return [];
153        }
154
155        return $this->source->listUserPoliciesByKey($definition->key());
156    }
157
158    /**
159     * A null value removes the explicit system rule, so the built-in default
160     * applies and lower layers may override it. Any other value, including one
161     * equal to the default, is stored as an explicit rule with its own
162     * override flag.
163     */
164    public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $allowChildOverride = false): ResolvedPolicy {
165        if ($value === null) {
166            return $this->clearSystem($policyKey);
167        }
168
169        $context = $this->contextFactory->forCurrentUser();
170        $definition = $this->registry->get($policyKey);
171        $this->assertScopeSupported($definition, PolicySpec::SCOPE_SYSTEM);
172        $normalizedValue = $definition->normalizeValue($value);
173
174        $definition->validateValueForPersistence($normalizedValue, $context);
175        $this->source->saveSystemPolicy($definition->key(), $normalizedValue, $allowChildOverride);
176
177        return $this->resolver->resolve(
178            $definition,
179            $this->contextFactory->forUserId(null),
180        );
181    }
182
183    public function clearSystem(string|\BackedEnum $policyKey): ResolvedPolicy {
184        $definition = $this->registry->get($policyKey);
185        $this->assertScopeSupported($definition, PolicySpec::SCOPE_SYSTEM);
186        $this->source->clearSystemPolicy($definition->key());
187
188        return $this->resolver->resolve(
189            $definition,
190            $this->contextFactory->forUserId(null),
191        );
192    }
193
194    public function getGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer {
195        $definition = $this->registry->get($policyKey);
196        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
197            return null;
198        }
199
200        return $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
201    }
202
203    /**
204     * @return list<array{targetId: string, policy: PolicyLayer}>
205     */
206    public function listGroupPolicies(string|\BackedEnum $policyKey): array {
207        $definition = $this->registry->get($policyKey);
208        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
209            return [];
210        }
211
212        return $this->source->listGroupPoliciesByKey($definition->key());
213    }
214
215    /**
216     * @param list<string> $groupIds
217     * @return list<array{targetId: string, policy: PolicyLayer}>
218     */
219    public function listGroupPoliciesForTargets(string|\BackedEnum $policyKey, array $groupIds): array {
220        $definition = $this->registry->get($policyKey);
221        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
222            return [];
223        }
224
225        return $this->source->listGroupPoliciesByKeyForTargets($definition->key(), $groupIds);
226    }
227
228    /**
229     * @param list<string> $groupIds
230     */
231    public function countVisibleGroupPoliciesForTargets(string|\BackedEnum $policyKey, array $groupIds): int {
232        $definition = $this->registry->get($policyKey);
233        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
234            return 0;
235        }
236
237        /** @var array<string, true> $visibleGroupIds */
238        $visibleGroupIds = [];
239
240        foreach ($this->getCountedPolicyKeys($definition) as $policyKey) {
241            foreach ($this->source->listGroupPoliciesByKeyForTargets($policyKey, $groupIds) as $record) {
242                $groupId = (string)($record['targetId'] ?? '');
243                $policy = $record['policy'] ?? null;
244                if ($groupId === '' || !$policy instanceof PolicyLayer) {
245                    continue;
246                }
247
248                if (!$this->canViewGroupPolicy($policyKey, $groupId, $policy)) {
249                    continue;
250                }
251
252                $visibleGroupIds[$groupId] = true;
253            }
254        }
255
256        return count($visibleGroupIds);
257    }
258
259    /**
260     * A group with a rule on a composite child edited through this policy also counts for it.
261     *
262     * @return list<string>
263     */
264    private function getCountedPolicyKeys(IPolicyDefinition $definition): array {
265        $policyKeys = [$definition->key()];
266        foreach ($definition->compositeChildren() as $childKey) {
267            if ($this->registry->get($childKey)->parentPolicyKey() === $definition->key()) {
268                $policyKeys[] = $childKey;
269            }
270        }
271
272        return $policyKeys;
273    }
274
275    public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId, mixed $value, bool $allowChildOverride): PolicyLayer {
276        $definition = $this->registry->get($policyKey);
277        $this->assertScopeSupported($definition, PolicySpec::SCOPE_GROUP);
278        $context = $this->contextFactory->forCurrentUser();
279        $this->assertCurrentActorCanManageGroupPolicy($definition->key(), $context);
280        $this->assertCurrentActorCanEditGroupPolicy($definition->key(), $groupId, $context);
281        $normalizedValue = $definition->normalizeValue($value);
282        $definition->validateValueForPersistence($normalizedValue, $context);
283        $createdBySystemAdmin = $context->getActorRole()->canManageSystemPolicies;
284        $this->source->saveGroupPolicy(
285            $definition->key(),
286            $groupId,
287            $normalizedValue,
288            $allowChildOverride,
289            $createdBySystemAdmin,
290            $context,
291        );
292
293        return $this->source->loadGroupPolicyConfig($definition->key(), $groupId)
294            ?? (new PolicyLayer())
295                ->setScope('group')
296                ->setVisibleToChild(true)
297                ->setAllowChildOverride(true)
298                ->setAllowedValues([]);
299    }
300
301    public function clearGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer {
302        $definition = $this->registry->get($policyKey);
303        $this->assertScopeSupported($definition, PolicySpec::SCOPE_GROUP);
304        $this->assertCurrentActorCanDeleteGroupPolicy($definition->key(), $groupId);
305        $this->source->clearGroupPolicy(
306            $definition->key(),
307            $groupId,
308            !$this->contextFactory->isCurrentActorSystemAdmin(),
309        );
310
311        return $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
312    }
313
314    public function canDeleteGroupPolicy(string|\BackedEnum $policyKey, string $groupId, ?PolicyLayer $policy = null): bool {
315        $definition = $this->registry->get($policyKey);
316        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
317            return false;
318        }
319
320        if ($this->contextFactory->isCurrentActorSystemAdmin()) {
321            return true;
322        }
323
324        $groupPolicy = $policy ?? $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
325        if (!$groupPolicy instanceof PolicyLayer) {
326            return false;
327        }
328
329        return !$groupPolicy->isCreatedBySystemAdmin();
330    }
331
332    public function canViewGroupPolicy(string|\BackedEnum $policyKey, string $groupId, ?PolicyLayer $policy = null): bool {
333        $definition = $this->registry->get($policyKey);
334        if (!$definition->supportsScope(PolicySpec::SCOPE_GROUP)) {
335            return false;
336        }
337
338        if ($this->contextFactory->isCurrentActorSystemAdmin()) {
339            return true;
340        }
341
342        $groupPolicy = $policy ?? $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
343        if (!$groupPolicy instanceof PolicyLayer) {
344            return true;
345        }
346
347        return !$this->shouldHideSystemCreatedGroupRuleFromCurrentActor($definition->key(), $groupPolicy);
348    }
349
350    public function shouldFilterVisibleGroupCountsForCurrentActor(string|\BackedEnum $policyKey): bool {
351        $definition = $this->registry->get($policyKey);
352        return $definition->shouldFilterVisibleGroupCountsForActor(
353            $this->contextFactory->forCurrentUser(),
354            $this->source->loadSystemPolicy($definition->key()),
355        );
356    }
357
358    public function canManageUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): bool {
359        $definition = $this->registry->get($policyKey);
360        if (!$definition->supportsScope(PolicySpec::SCOPE_USER)) {
361            return false;
362        }
363
364        if ($this->contextFactory->isCurrentActorSystemAdmin()) {
365            return true;
366        }
367
368        $resolved = $this->resolver->resolve(
369            $definition,
370            $this->contextFactory->forUserId($userId),
371        );
372
373        return $resolved->canSaveAsUserDefault()
374            || (($resolved->getMeta()['canCreateDescendantRules'] ?? false) === true);
375    }
376
377    private function assertCurrentActorCanDeleteGroupPolicy(string $policyKey, string $groupId): void {
378        if ($this->canDeleteGroupPolicy($policyKey, $groupId)) {
379            return;
380        }
381
382        // TRANSLATORS Permission error shown when a non-admin tries to delete a group policy rule created by a system administrator.
383        throw new \DomainException($this->l10n->t('Only system administrators can delete group rules created by a system administrator'));
384    }
385
386    private function assertCurrentActorCanEditGroupPolicy(string $policyKey, string $groupId, ?PolicyContext $context = null): void {
387        $context ??= $this->contextFactory->forCurrentUser();
388        if ($context->getActorRole()->canManageSystemPolicies) {
389            return;
390        }
391
392        $definition = $this->registry->get($policyKey);
393        $existingPolicy = $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
394        if (!$existingPolicy instanceof PolicyLayer) {
395            return;
396        }
397
398        if (!$this->wasGroupPolicyCreatedBySystemAdmin($existingPolicy)) {
399            return;
400        }
401
402        if ($definition->canCurrentActorEditSystemCreatedGroupPolicy(
403            $context,
404            $this->source->loadSystemPolicy($definition->key()),
405            $existingPolicy,
406        )) {
407            return;
408        }
409
410        // TRANSLATORS Permission error shown when a non-admin tries to edit a group access rule created by a system administrator.
411        throw new \DomainException($this->l10n->t('Only system administrators can edit group access rules created by a system administrator'));
412    }
413
414    private function wasGroupPolicyCreatedBySystemAdmin(PolicyLayer $policy): bool {
415        return $policy->isCreatedBySystemAdmin();
416    }
417
418    private function shouldHideSystemCreatedGroupRuleFromCurrentActor(string $policyKey, PolicyLayer $policy): bool {
419        if (!$this->wasGroupPolicyCreatedBySystemAdmin($policy)) {
420            return false;
421        }
422
423        return $this->shouldFilterVisibleGroupCountsForCurrentActor($policyKey);
424    }
425
426    private function assertCurrentActorCanManageGroupPolicy(string $policyKey, ?PolicyContext $context = null): void {
427        $context ??= $this->contextFactory->forCurrentUser();
428        if ($context->getActorRole()->canManageSystemPolicies) {
429            return;
430        }
431
432        $definition = $this->registry->get($policyKey);
433        if (!$this->canCurrentActorManageGroupPolicy($definition, $context)) {
434            // TRANSLATORS Permission error shown when managing group policies without an explicit delegation from a system administrator.
435            throw new \DomainException($this->l10n->t('Group policy management requires explicit delegation from the system administrator'));
436        }
437    }
438
439    private function canCurrentActorManageGroupPolicy(IPolicyDefinition $definition, PolicyContext $context): bool {
440        return $definition->canCurrentActorManageGroupPolicy(
441            $context,
442            $this->source->loadSystemPolicy($definition->key()),
443            $this->source->loadGroupPolicies($definition->key(), $context),
444        );
445    }
446
447    private function assertScopeSupported(IPolicyDefinition $definition, string $scope): void {
448        if ($definition->supportsScope($scope)) {
449            return;
450        }
451
452        $scopeLabel = match ($scope) {
453            PolicySpec::SCOPE_SYSTEM => 'System',
454            PolicySpec::SCOPE_GROUP => 'Group',
455            PolicySpec::SCOPE_USER => 'User',
456            default => ucfirst($scope),
457        };
458
459        // TRANSLATORS Error shown when saving a LibreSign policy with an unsupported scope. %s is the scope label such as user or group.
460        throw new \InvalidArgumentException($this->l10n->t('%s-level scope is not supported for this policy', [$scopeLabel]));
461    }
462
463    public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy {
464        $context = $this->contextFactory->forCurrentUser();
465        $definition = $this->registry->get($policyKey);
466        $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
467        $normalizedValue = $definition->normalizeValue($value);
468        $resolved = $this->resolver->resolve($definition, $context);
469        if (!$resolved->canSaveAsUserDefault()) {
470            // TRANSLATORS Error shown when saving a user preference for a policy that does not allow personal overrides. {policyKey} is the policy identifier.
471            throw new \InvalidArgumentException($this->l10n->t('Saving a user preference is not allowed for {policyKey}', [
472                'policyKey' => $definition->key(),
473            ]));
474        }
475
476        $definition->validateValueForPersistence($normalizedValue, $context);
477        $this->source->saveUserPreference($definition->key(), $context, $normalizedValue);
478
479        return $this->resolver->resolve($definition, $context);
480    }
481
482    public function clearUserPreference(string|\BackedEnum $policyKey): ResolvedPolicy {
483        $context = $this->contextFactory->forCurrentUser();
484        $definition = $this->registry->get($policyKey);
485        $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
486        $this->source->clearUserPreference($definition->key(), $context);
487
488        return $this->resolver->resolve($definition, $context);
489    }
490
491    public function saveUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value, bool $allowChildOverride): ?PolicyLayer {
492        $context = $this->contextFactory->forUserId($userId);
493        $definition = $this->registry->get($policyKey);
494        $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
495        $normalizedValue = $definition->normalizeValue($value);
496        $definition->validateValueForPersistence($normalizedValue, $context);
497        $this->source->saveUserPolicy($definition->key(), $context, $normalizedValue, $allowChildOverride);
498
499        return $this->source->loadUserPolicy($definition->key(), $context)
500            ?? (new PolicyLayer())
501                ->setScope('user_policy')
502                ->setValue($normalizedValue)
503                ->setAllowChildOverride($allowChildOverride)
504                ->setVisibleToChild(true);
505    }
506
507    public function clearUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer {
508        $context = $this->contextFactory->forUserId($userId);
509        $definition = $this->registry->get($policyKey);
510        $this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
511        $this->source->clearUserPolicy($definition->key(), $context);
512
513        return $this->source->loadUserPolicy($definition->key(), $context);
514    }
515
516    /**
517     * Save several values of the same composite policy family at the system layer.
518     *
519     * @param array<string, mixed> $values Values to persist, keyed by policy key
520     * @param array<string, bool> $allowChildOverride Override flag of each key, defaulting to false
521     * @return array<string, ResolvedPolicy>
522     */
523    public function saveSystemCompound(string $parentPolicyKey, array $values, array $allowChildOverride = []): array {
524        $this->validateCompositeWrite($parentPolicyKey, $values, true, $this->contextFactory->forUserId(null));
525
526        $saved = [];
527        $this->inTransaction(function () use ($values, $allowChildOverride, &$saved): void {
528            foreach ($values as $policyKey => $value) {
529                $saved[$policyKey] = $this->saveSystem($policyKey, $value, $allowChildOverride[$policyKey] ?? false);
530            }
531        });
532
533        return $saved;
534    }
535
536    /**
537     * Save several values of the same composite policy family for one group.
538     *
539     * @param array<string, mixed> $values Values to persist, keyed by policy key
540     * @param array<string, bool> $allowChildOverride Override flag of each key, defaulting to false
541     * @return array<string, PolicyLayer>
542     */
543    public function saveGroupPolicyCompound(string $parentPolicyKey, string $groupId, array $values, array $allowChildOverride = []): array {
544        $this->validateCompositeWrite($parentPolicyKey, $values, false, $this->contextFactory->forUserId(null)->setGroups([$groupId]));
545
546        $saved = [];
547        $this->inTransaction(function () use ($groupId, $values, $allowChildOverride, &$saved): void {
548            foreach ($values as $policyKey => $value) {
549                $saved[$policyKey] = $this->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride[$policyKey] ?? false);
550            }
551        });
552
553        return $saved;
554    }
555
556    /**
557     * Save several values of the same composite policy family as the current
558     * user's personal defaults.
559     *
560     * @param array<string, mixed> $values Values to persist, keyed by policy key
561     * @return array<string, ResolvedPolicy>
562     */
563    public function saveUserPreferenceCompound(string $parentPolicyKey, array $values): array {
564        $this->validateCompositeWrite($parentPolicyKey, $values, false, $this->contextFactory->forCurrentUser());
565
566        $saved = [];
567        $this->inTransaction(function () use ($values, &$saved): void {
568            foreach ($values as $policyKey => $value) {
569                $saved[$policyKey] = $this->saveUserPreference($policyKey, $value);
570            }
571        });
572
573        return $saved;
574    }
575
576    /**
577     * Save several values of the same composite policy family for one user.
578     *
579     * @param array<string, mixed> $values Values to persist, keyed by policy key
580     * @param array<string, bool> $allowChildOverride Override flag of each key, defaulting to false
581     * @return array<string, ?PolicyLayer>
582     */
583    public function saveUserPolicyForUserIdCompound(string $parentPolicyKey, string $userId, array $values, array $allowChildOverride = []): array {
584        $this->validateCompositeWrite($parentPolicyKey, $values, false, $this->contextFactory->forUserId($userId));
585
586        $saved = [];
587        $this->inTransaction(function () use ($userId, $values, $allowChildOverride, &$saved): void {
588            foreach ($values as $policyKey => $value) {
589                $saved[$policyKey] = $this->saveUserPolicyForUserId($policyKey, $userId, $value, $allowChildOverride[$policyKey] ?? false);
590            }
591        });
592
593        return $saved;
594    }
595
596    /**
597     * Hand the complete intended configuration of a composite family to the
598     * policy that owns it, before a single key is written.
599     *
600     * The framework knows which keys belong together and which ones the caller
601     * is writing; the keys left out keep the value they already resolve to in
602     * the same scope, so the policy always sees the configuration the write is
603     * about to produce and the outcome does not depend on the order in which
604     * the keys are persisted. Whether that configuration makes sense is a
605     * question only the policy itself can answer.
606     *
607     * @param array<string, mixed> $values
608     */
609    private function validateCompositeWrite(string $parentPolicyKey, array $values, bool $nullRestoresDefault, PolicyContext $context): void {
610        $parentDefinition = $this->registry->get($parentPolicyKey);
611        $definitions = $this->resolveCompositeFamily($parentDefinition, $values);
612        $normalizedValues = $this->normalizeCompositeValues($definitions, $values, $nullRestoresDefault);
613
614        $intendedValues = $normalizedValues;
615        foreach ($definitions as $policyKey => $definition) {
616            if (array_key_exists($policyKey, $intendedValues)) {
617                continue;
618            }
619
620            $intendedValues[$policyKey] = $this->resolver->resolve($definition, $context)->getEffectiveValue();
621        }
622
623        $parentDefinition->validateCompositeValuesForPersistence(
624            $intendedValues,
625            array_keys($normalizedValues),
626            $context,
627        );
628    }
629
630    /**
631     * The definitions that make up a composite policy family, keyed by policy
632     * key, once every submitted key is known to belong to it.
633     *
634     * @param array<string, mixed> $values
635     * @return array<string, IPolicyDefinition>
636     */
637    private function resolveCompositeFamily(IPolicyDefinition $parentDefinition, array $values): array {
638        if ($parentDefinition->compositeChildren() === []) {
639            // TRANSLATORS Error shown when several policy values are saved at once for a policy that has no other settings attached to it. {policyKey} is the policy identifier.
640            throw new \InvalidArgumentException($this->l10n->t('{policyKey} does not group other policy settings', [
641                'policyKey' => $parentDefinition->key(),
642            ]));
643        }
644
645        if ($values === []) {
646            // TRANSLATORS Error shown when a request to save several policy values at once carries no value at all.
647            throw new \InvalidArgumentException($this->l10n->t('No policy value was sent'));
648        }
649
650        $definitions = [$parentDefinition->key() => $parentDefinition];
651        foreach ($parentDefinition->compositeChildren() as $childKey) {
652            $definitions[$childKey] = $this->registry->get($childKey);
653        }
654
655        foreach (array_keys($values) as $policyKey) {
656            if (isset($definitions[$policyKey])) {
657                continue;
658            }
659
660            // TRANSLATORS Error shown when a policy value is saved together with settings it does not belong to. {policyKey} is the policy identifier and {parentPolicyKey} is the setting the others are grouped under.
661            throw new \InvalidArgumentException($this->l10n->t('{policyKey} is not part of {parentPolicyKey}', [
662                'policyKey' => (string)$policyKey,
663                'parentPolicyKey' => $parentDefinition->key(),
664            ]));
665        }
666
667        return $definitions;
668    }
669
670    /**
671     * @param array<string, IPolicyDefinition> $definitions
672     * @param array<string, mixed> $values
673     * @return array<string, mixed>
674     */
675    private function normalizeCompositeValues(array $definitions, array $values, bool $nullRestoresDefault): array {
676        $normalizedValues = [];
677        foreach ($values as $policyKey => $value) {
678            $definition = $definitions[$policyKey];
679            $normalizedValues[$policyKey] = $nullRestoresDefault && $value === null
680                ? $definition->normalizeValue($definition->defaultSystemValue())
681                : $definition->normalizeValue($value);
682        }
683
684        return $normalizedValues;
685    }
686
687    /**
688     * A composite write is one change made of several persisted values, so
689     * either all of them are stored or none is.
690     */
691    private function inTransaction(callable $operation): void {
692        $this->db->beginTransaction();
693        try {
694            $operation();
695            $this->db->commit();
696        } catch (\Throwable $exception) {
697            $this->db->rollBack();
698
699            throw $exception;
700        }
701    }
702
703    /**
704     * @param list<string> $groupIds
705     * @param list<string> $userIds
706     * @return array<string, array{groupCount: int, userCount: int, everyoneCount: int}>
707     */
708    public function getRuleCounts(array $groupIds, array $userIds): array {
709        return $this->source->loadRuleCounts($groupIds, $userIds);
710    }
711
712    /** @return array<string, array{groupCount: int, userCount: int, everyoneCount: int}> */
713    public function getAllRuleCounts(): array {
714        return $this->source->loadAllRuleCounts();
715    }
716
717    /**
718     * @param array<string, ResolvedPolicy> $resolvedPolicies
719     * @param null|array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $ruleCounts
720     * @return array<string, array<string, mixed>>
721     */
722    private function serializeResolvedPolicies(array $resolvedPolicies, ?array $ruleCounts = null): array {
723        $states = [];
724        foreach ($resolvedPolicies as $policyKey => $resolvedPolicy) {
725            $policyState = $resolvedPolicy->toArray();
726            if ($ruleCounts !== null) {
727                $policyState['groupCount'] = $ruleCounts[$policyKey]['groupCount'] ?? 0;
728                $policyState['userCount'] = $ruleCounts[$policyKey]['userCount'] ?? 0;
729                $policyState['everyoneCount'] = $ruleCounts[$policyKey]['everyoneCount'] ?? 0;
730            }
731
732            $states[$policyKey] = $policyState;
733        }
734
735        return $states;
736    }
737
738    /** @return array<string, ResolvedPolicy> */
739    private function resolveKnownPoliciesForContext(PolicyContext $context): array {
740        $definitions = [];
741        foreach ($this->registry->getAllPolicyKeys() as $policyKey) {
742            $definitions[] = $this->registry->get($policyKey);
743        }
744
745        return $this->resolver->resolveMany($definitions, $context);
746    }
747}