Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.18% covered (success)
90.18%
560 / 621
59.26% covered (warning)
59.26%
32 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
PolicySource
90.18% covered (success)
90.18%
560 / 621
59.26% covered (warning)
59.26%
32 / 54
270.13
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
 loadSystemPolicy
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
10
 loadGroupPolicies
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
9.03
 loadCirclePolicies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadUserPolicy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 loadUserPreference
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 loadUserPolicyConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 listUserPoliciesByKey
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 loadAllGroupPolicies
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
9
 loadAllUserPolicies
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
10
 loadAllUserPreferences
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 loadRequestOverride
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 loadGroupPolicyConfig
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 listGroupPoliciesByKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 listGroupPoliciesByKeyForTargets
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 buildGroupPoliciesByKeyFromBindings
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
6.02
 loadRuleCounts
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
6
 loadAllRuleCounts
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 hydrateUserRuleCounts
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
8
 getParentPolicyKeys
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 hydrateExplicitSystemRuleCounts
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
9
 saveSystemPolicy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 clearSystemPolicy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 readSystemValue
66.67% covered (warning)
66.67%
16 / 24
0.00% covered (danger)
0.00%
0 / 1
19.26
 writeSystemValue
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
7.61
 setSystemValueByType
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 deserializeStoredValueWithType
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getSystemAllowOverrideConfigKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAssignedUserPolicyKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeStoredValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deserializeStoredValue
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 serializeStoredUserPolicyPayload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createUserPolicyLayerFromPayload
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 deserializeStoredUserPolicyPayload
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveStoredGroupPolicyConfig
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 hasExplicitGroupPolicyConfig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isGroupPolicyConfigCreatedBySystemAdmin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildStoredGroupPolicyConfig
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 incrementGroupRuleCounts
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
13.59
 persistGroupPermissionSet
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 saveGroupPolicy
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 clearGroupPolicy
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
9.11
 saveUserPreference
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 saveUserPolicy
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 clearUserPreference
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 clearUserPolicy
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveGroupIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 createGroupPolicyLayer
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 resolveCreatedBySystemAdmin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDelegatedGroupAdminOverrideKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tryResolveDelegatedOverrideBaseKey
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 findBindingByGroupId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 findPermissionSetByBinding
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 findPermissionSetByGroupId
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\Runtime;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Db\PermissionSet;
13use OCA\Libresign\Db\PermissionSetBinding;
14use OCA\Libresign\Db\PermissionSetBindingMapper;
15use OCA\Libresign\Db\PermissionSetMapper;
16use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
17use OCA\Libresign\Service\Policy\Contract\IPolicySource;
18use OCA\Libresign\Service\Policy\Model\PolicyContext;
19use OCA\Libresign\Service\Policy\Model\PolicyLayer;
20use OCP\AppFramework\Db\DoesNotExistException;
21use OCP\AppFramework\Services\IAppConfig;
22use OCP\DB\QueryBuilder\IQueryBuilder;
23use OCP\Exceptions\AppConfigTypeConflictException;
24use OCP\IDBConnection;
25use OCP\IL10N;
26
27class PolicySource implements IPolicySource {
28    public function __construct(
29        private IAppConfig $appConfig,
30        private PermissionSetMapper $permissionSetMapper,
31        private PermissionSetBindingMapper $bindingMapper,
32        private PolicyRegistry $registry,
33        private IDBConnection $db,
34        private IL10N $l10n,
35    ) {
36    }
37
38    #[\Override]
39    public function loadSystemPolicy(string $policyKey): ?PolicyLayer {
40        $definition = $this->registry->get($policyKey);
41        $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
42        $hasExplicitSystemValue = $this->appConfig->hasAppKey($definition->getAppConfigKey());
43        $storedValue = $hasExplicitSystemValue
44            ? $this->readSystemValue($definition->getAppConfigKey(), $defaultValue)
45            : null;
46
47        // The policy API stores the override flag with every value, so an empty
48        // string without it was written directly or cleared via a migration
49        // path. Treat it as having no explicit system value.
50        $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey());
51        $hasAllowOverrideFlag = $this->appConfig->hasAppKey($allowOverrideConfigKey);
52        if ($storedValue === '' && !$hasAllowOverrideFlag) {
53            $hasExplicitSystemValue = false;
54            $storedValue = null;
55        }
56
57        $value = $hasExplicitSystemValue
58            ? $definition->normalizeValue($storedValue)
59            : $defaultValue;
60
61        $layer = (new PolicyLayer())
62            ->setScope($hasExplicitSystemValue ? 'global' : 'system')
63            ->setValue($value)
64            ->setVisibleToChild(true);
65
66        if (!$hasExplicitSystemValue) {
67            return $layer->setAllowChildOverride(true);
68        }
69
70        // A default value stored without an override flag predates explicit
71        // defaults: the API could not enforce it then, so it stays overridable.
72        if ($value === $defaultValue && !$hasAllowOverrideFlag) {
73            return $layer->setAllowChildOverride(true);
74        }
75
76        $allowChildOverride = $this->appConfig->getAppValueString($allowOverrideConfigKey, '0') === '1';
77
78        return $layer
79            ->setAllowChildOverride($allowChildOverride)
80            ->setAllowedValues($allowChildOverride ? [] : [$value]);
81    }
82
83    #[\Override]
84    public function loadGroupPolicies(string $policyKey, PolicyContext $context): array {
85        $groupIds = $this->resolveGroupIds($context);
86        if ($groupIds === []) {
87            return [];
88        }
89
90        $bindingsByTargetId = [];
91        foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) {
92            $bindingsByTargetId[$binding->getTargetId()] = $binding;
93        }
94
95        $permissionSetIds = [];
96        foreach ($bindingsByTargetId as $binding) {
97            $permissionSetIds[] = $binding->getPermissionSetId();
98        }
99
100        $permissionSetsById = [];
101        foreach ($this->permissionSetMapper->findByIds(array_values(array_unique($permissionSetIds))) as $permissionSet) {
102            $permissionSetsById[$permissionSet->getId()] = $permissionSet;
103        }
104
105        $layers = [];
106
107        foreach ($groupIds as $groupId) {
108            $binding = $bindingsByTargetId[$groupId] ?? null;
109            if (!$binding instanceof PermissionSetBinding) {
110                continue;
111            }
112
113            $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null;
114            if (!$permissionSet instanceof PermissionSet) {
115                continue;
116            }
117
118            $policyConfig = $this->resolveStoredGroupPolicyConfig(
119                $permissionSet->getDecodedPolicyJson(),
120                $policyKey,
121            );
122            if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) {
123                continue;
124            }
125
126            $layers[] = $this->createGroupPolicyLayer($policyConfig);
127        }
128
129        return $layers;
130    }
131
132    #[\Override]
133    public function loadCirclePolicies(string $policyKey, PolicyContext $context): array {
134        return [];
135    }
136
137    #[\Override]
138    public function loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer {
139        $userId = $context->getUserId();
140        if ($userId === null || $userId === '') {
141            return null;
142        }
143
144        return $this->loadUserPolicyConfig($policyKey, $userId);
145    }
146
147    #[\Override]
148    public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer {
149        $userId = $context->getUserId();
150        if ($userId === null || $userId === '') {
151            return null;
152        }
153
154        $definition = $this->registry->get($policyKey);
155        $value = $this->appConfig->getUserValue($userId, $definition->getUserPreferenceKey(), '');
156        if ($value === '') {
157            return null;
158        }
159
160        return (new PolicyLayer())
161            ->setScope('user')
162            ->setValue($definition->normalizeValue($this->deserializeStoredValue($value)));
163    }
164
165    #[\Override]
166    public function loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer {
167        if ($userId === '') {
168            return null;
169        }
170
171        $definition = $this->registry->get($policyKey);
172        $storedPayload = $this->appConfig->getUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), '');
173        if ($storedPayload === '') {
174            return null;
175        }
176
177        return $this->createUserPolicyLayerFromPayload($definition, $storedPayload);
178    }
179
180    /**
181     * @return list<array{targetId: string, policy: PolicyLayer}>
182     */
183    #[\Override]
184    public function listUserPoliciesByKey(string $policyKey): array {
185        $definition = $this->registry->get($policyKey);
186        $assignedPolicyKey = $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey());
187
188        $query = $this->db->getQueryBuilder();
189        $query->select('userid', 'configvalue')
190            ->from('preferences')
191            ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID)))
192            ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter($assignedPolicyKey)))
193            ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter('')));
194
195        $result = $query->executeQuery();
196        $policies = [];
197        try {
198            while ($row = $result->fetchAssociative()) {
199                $userId = (string)($row['userid'] ?? '');
200                $payload = (string)($row['configvalue'] ?? '');
201                if ($userId === '' || $payload === '') {
202                    continue;
203                }
204
205                $policyLayer = $this->createUserPolicyLayerFromPayload($definition, $payload);
206                if (!$policyLayer instanceof PolicyLayer) {
207                    continue;
208                }
209
210                $policies[] = [
211                    'targetId' => $userId,
212                    'policy' => $policyLayer,
213                ];
214            }
215        } finally {
216            $result->closeCursor();
217        }
218
219        return $policies;
220    }
221
222    /**
223     * @param list<string> $policyKeys
224     * @return array<string, list<PolicyLayer>>
225     */
226    #[\Override]
227    public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array {
228        /** @var array<string, list<PolicyLayer>> $result */
229        $result = array_fill_keys($policyKeys, []);
230
231        $groupIds = $this->resolveGroupIds($context);
232        if ($groupIds === []) {
233            return $result;
234        }
235
236        $bindingsByTargetId = [];
237        foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) {
238            $bindingsByTargetId[$binding->getTargetId()] = $binding;
239        }
240
241        $permissionSetIds = array_values(array_unique(array_map(
242            static fn (PermissionSetBinding $b): int => $b->getPermissionSetId(),
243            $bindingsByTargetId,
244        )));
245
246        $permissionSetsById = [];
247        foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) {
248            $permissionSetsById[$permissionSet->getId()] = $permissionSet;
249        }
250
251        foreach ($groupIds as $groupId) {
252            $binding = $bindingsByTargetId[$groupId] ?? null;
253            if (!$binding instanceof PermissionSetBinding) {
254                continue;
255            }
256
257            $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null;
258            if (!$permissionSet instanceof PermissionSet) {
259                continue;
260            }
261
262            $policyJson = $permissionSet->getDecodedPolicyJson();
263            foreach ($policyKeys as $policyKey) {
264                $policyConfig = $this->resolveStoredGroupPolicyConfig($policyJson, $policyKey);
265                if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) {
266                    continue;
267                }
268
269                $result[$policyKey][] = $this->createGroupPolicyLayer($policyConfig);
270            }
271        }
272
273        return $result;
274    }
275
276    /**
277     * @param list<string> $policyKeys
278     * @return array<string, PolicyLayer>
279     */
280    #[\Override]
281    public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array {
282        $userId = $context->getUserId();
283        if ($userId === null || $userId === '') {
284            return [];
285        }
286
287        $userPolicyKeyByPolicy = [];
288        foreach ($policyKeys as $policyKey) {
289            $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey());
290        }
291        $policyKeyByAssignedKey = array_flip($userPolicyKeyByPolicy);
292
293        $query = $this->db->getQueryBuilder();
294        $query->select('configkey', 'configvalue')
295            ->from('preferences')
296            ->where($query->expr()->eq('userid', $query->createNamedParameter($userId)))
297            ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID)))
298            ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY)))
299            ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter('')));
300
301        $result = $query->executeQuery();
302        $layers = [];
303        try {
304            while ($row = $result->fetchAssociative()) {
305                $policyKey = $policyKeyByAssignedKey[$row['configkey']] ?? null;
306                if ($policyKey === null) {
307                    continue;
308                }
309
310                $definition = $this->registry->get($policyKey);
311                $decodedPayload = $this->deserializeStoredUserPolicyPayload($row['configvalue']);
312                if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) {
313                    continue;
314                }
315
316                $normalizedValue = $definition->normalizeValue($decodedPayload['value']);
317                $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false);
318                $layers[$policyKey] = (new PolicyLayer())
319                    ->setScope('user_policy')
320                    ->setValue($normalizedValue)
321                    ->setAllowChildOverride($allowChildOverride)
322                    ->setVisibleToChild(true)
323                    ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]);
324            }
325        } finally {
326            $result->closeCursor();
327        }
328
329        return $layers;
330    }
331
332    /**
333     * @param list<string> $policyKeys
334     * @return array<string, PolicyLayer>
335     */
336    #[\Override]
337    public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array {
338        $userId = $context->getUserId();
339        if ($userId === null || $userId === '') {
340            return [];
341        }
342
343        $userPreferenceKeyByPolicy = [];
344        foreach ($policyKeys as $policyKey) {
345            $userPreferenceKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getUserPreferenceKey();
346        }
347        $policyKeyByPreferenceKey = array_flip($userPreferenceKeyByPolicy);
348
349        $query = $this->db->getQueryBuilder();
350        $query->select('configkey', 'configvalue')
351            ->from('preferences')
352            ->where($query->expr()->eq('userid', $query->createNamedParameter($userId)))
353            ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID)))
354            ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPreferenceKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY)))
355            ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter('')));
356
357        $result = $query->executeQuery();
358        $layers = [];
359        try {
360            while ($row = $result->fetchAssociative()) {
361                $policyKey = $policyKeyByPreferenceKey[$row['configkey']] ?? null;
362                if ($policyKey === null) {
363                    continue;
364                }
365
366                $definition = $this->registry->get($policyKey);
367                $layers[$policyKey] = (new PolicyLayer())
368                    ->setScope('user')
369                    ->setValue($definition->normalizeValue($this->deserializeStoredValue($row['configvalue'])));
370            }
371        } finally {
372            $result->closeCursor();
373        }
374
375        return $layers;
376    }
377
378    #[\Override]
379    public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer {
380        $requestOverrides = $context->getRequestOverrides() + $context->getStoredRequestOverrides();
381        if (!array_key_exists($policyKey, $requestOverrides)) {
382            return null;
383        }
384
385        $definition = $this->registry->get($policyKey);
386
387        return (new PolicyLayer())
388            ->setScope('request')
389            ->setValue($definition->normalizeValue($requestOverrides[$policyKey]));
390    }
391
392    #[\Override]
393    public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer {
394        $permissionSet = $this->findPermissionSetByGroupId($groupId);
395        if (!$permissionSet instanceof PermissionSet) {
396            return null;
397        }
398
399        $policyConfig = $this->resolveStoredGroupPolicyConfig(
400            $permissionSet->getDecodedPolicyJson(),
401            $policyKey,
402        );
403        if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) {
404            return null;
405        }
406
407        return $this->createGroupPolicyLayer($policyConfig);
408    }
409
410    /**
411     * @return list<array{targetId: string, policy: PolicyLayer}>
412     */
413    #[\Override]
414    public function listGroupPoliciesByKey(string $policyKey): array {
415        return $this->buildGroupPoliciesByKeyFromBindings(
416            $policyKey,
417            $this->bindingMapper->findByTargetType('group'),
418        );
419    }
420
421    /**
422     * @param list<string> $groupIds
423     * @return list<array{targetId: string, policy: PolicyLayer}>
424     */
425    #[\Override]
426    public function listGroupPoliciesByKeyForTargets(string $policyKey, array $groupIds): array {
427        $groupIds = array_values(array_unique(array_filter(
428            $groupIds,
429            static fn (string $groupId): bool => $groupId !== '',
430        )));
431        if ($groupIds === []) {
432            return [];
433        }
434
435        return $this->buildGroupPoliciesByKeyFromBindings(
436            $policyKey,
437            $this->bindingMapper->findByTargets('group', $groupIds),
438        );
439    }
440
441    /**
442     * @param list<PermissionSetBinding> $bindings
443     * @return list<array{targetId: string, policy: PolicyLayer}>
444     */
445    private function buildGroupPoliciesByKeyFromBindings(string $policyKey, array $bindings): array {
446        if ($bindings === []) {
447            return [];
448        }
449
450        $permissionSetIds = array_values(array_unique(array_map(
451            static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(),
452            $bindings,
453        )));
454
455        $permissionSetsById = [];
456        foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) {
457            $permissionSetsById[$permissionSet->getId()] = $permissionSet;
458        }
459
460        $policies = [];
461        foreach ($bindings as $binding) {
462            $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null;
463            if (!$permissionSet instanceof PermissionSet) {
464                continue;
465            }
466
467            $policyConfig = $this->resolveStoredGroupPolicyConfig(
468                $permissionSet->getDecodedPolicyJson(),
469                $policyKey,
470            );
471            if (!$this->hasExplicitGroupPolicyConfig($policyConfig)) {
472                continue;
473            }
474
475            $policies[] = [
476                'targetId' => $binding->getTargetId(),
477                'policy' => $this->createGroupPolicyLayer($policyConfig),
478            ];
479        }
480
481        return $policies;
482    }
483
484    /**
485     * @param list<string> $groupIds
486     * @param list<string> $userIds
487     * @return array<string, array{groupCount: int, userCount: int, everyoneCount: int}>
488     */
489    public function loadRuleCounts(array $groupIds, array $userIds): array {
490        $policyKeys = $this->registry->getAllPolicyKeys();
491        /** @var array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $counts */
492        $counts = [];
493        foreach ($policyKeys as $policyKey) {
494            $counts[$policyKey] = [
495                'groupCount' => 0,
496                'userCount' => 0,
497                'everyoneCount' => 0,
498            ];
499        }
500
501        $groupIds = array_values(array_unique(array_filter($groupIds, static fn (string $groupId): bool => $groupId !== '')));
502        if ($groupIds !== []) {
503            $groupBindings = $this->bindingMapper->findByTargets('group', $groupIds);
504            $permissionSetIds = array_values(array_unique(array_map(
505                static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(),
506                $groupBindings,
507            )));
508
509            $permissionSetsById = [];
510            foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) {
511                $permissionSetsById[$permissionSet->getId()] = $permissionSet;
512            }
513
514            $parentPolicyKeys = $this->getParentPolicyKeys($policyKeys);
515            foreach ($groupBindings as $binding) {
516                $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? [];
517                $this->incrementGroupRuleCounts($counts, $policyJson, $parentPolicyKeys);
518            }
519        }
520
521        $userIds = array_values(array_unique(array_filter($userIds, static fn (string $userId): bool => $userId !== '')));
522        if ($userIds !== []) {
523            $this->hydrateUserRuleCounts($policyKeys, $counts, $userIds);
524        }
525
526        $this->hydrateExplicitSystemRuleCounts($policyKeys, $counts);
527
528        return $counts;
529    }
530
531    /**
532     * Count group/user/system rules for ALL known targets (no ID filter). Suitable for system admins.
533     *
534     * @return array<string, array{groupCount: int, userCount: int, everyoneCount: int}>
535     */
536    public function loadAllRuleCounts(): array {
537        $policyKeys = $this->registry->getAllPolicyKeys();
538        /** @var array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $counts */
539        $counts = [];
540        foreach ($policyKeys as $policyKey) {
541            $counts[$policyKey] = ['groupCount' => 0, 'userCount' => 0, 'everyoneCount' => 0];
542        }
543
544        $groupBindings = $this->bindingMapper->findByTargetType('group');
545        if ($groupBindings !== []) {
546            $permissionSetIds = array_values(array_unique(array_map(
547                static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(),
548                $groupBindings,
549            )));
550
551            $permissionSetsById = [];
552            foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) {
553                $permissionSetsById[$permissionSet->getId()] = $permissionSet;
554            }
555
556            $parentPolicyKeys = $this->getParentPolicyKeys($policyKeys);
557            foreach ($groupBindings as $binding) {
558                $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? [];
559                $this->incrementGroupRuleCounts($counts, $policyJson, $parentPolicyKeys);
560            }
561        }
562
563        $this->hydrateUserRuleCounts($policyKeys, $counts);
564
565        $this->hydrateExplicitSystemRuleCounts($policyKeys, $counts);
566
567        return $counts;
568    }
569
570    /**
571     * Counts each user once per policy. A user with a rule on a composite child
572     * also counts for its parent, so the parent summary reflects distinct targets.
573     *
574     * @param list<string> $policyKeys
575     * @param array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $counts
576     * @param list<string>|null $userIds null counts all users
577     */
578    private function hydrateUserRuleCounts(array $policyKeys, array &$counts, ?array $userIds = null): void {
579        $userPolicyKeyByPolicy = [];
580        foreach ($policyKeys as $policyKey) {
581            $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey());
582        }
583        $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy);
584
585        $query = $this->db->getQueryBuilder();
586        $query->selectDistinct(['configkey', 'userid'])
587            ->from('preferences')
588            ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID)));
589        if ($userIds !== null) {
590            $query->andWhere($query->expr()->in('userid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)));
591        }
592        $query->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY)))
593            ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter('')));
594
595        /** @var array<string, array<string, true>> $userIdsByPolicy */
596        $userIdsByPolicy = [];
597        $result = $query->executeQuery();
598        try {
599            while ($row = $result->fetchAssociative()) {
600                $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null;
601                if (!is_string($policyKey) || !isset($counts[$policyKey])) {
602                    continue;
603                }
604
605                $userIdsByPolicy[$policyKey][(string)$row['userid']] = true;
606            }
607        } finally {
608            $result->closeCursor();
609        }
610
611        foreach ($this->getParentPolicyKeys($policyKeys) as $childKey => $parentKey) {
612            $userIdsByPolicy[$parentKey] = ($userIdsByPolicy[$parentKey] ?? []) + ($userIdsByPolicy[$childKey] ?? []);
613        }
614
615        foreach ($userIdsByPolicy as $policyKey => $policyUserIds) {
616            $counts[$policyKey]['userCount'] = count($policyUserIds);
617        }
618    }
619
620    /**
621     * Maps composite children that are edited through their parent to that parent.
622     *
623     * @param list<string> $policyKeys
624     * @return array<string, string>
625     */
626    private function getParentPolicyKeys(array $policyKeys): array {
627        $parentPolicyKeys = [];
628        foreach ($policyKeys as $policyKey) {
629            $parentKey = $this->registry->get($policyKey)->parentPolicyKey();
630            if ($parentKey !== null && in_array($parentKey, $policyKeys, true)) {
631                $parentPolicyKeys[$policyKey] = $parentKey;
632            }
633        }
634
635        return $parentPolicyKeys;
636    }
637
638    /**
639     * @param list<string> $policyKeys
640     * @param array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $counts
641     */
642    private function hydrateExplicitSystemRuleCounts(array $policyKeys, array &$counts): void {
643        $configKeyByPolicy = [];
644        foreach ($policyKeys as $policyKey) {
645            $configKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getAppConfigKey();
646        }
647
648        if ($configKeyByPolicy === []) {
649            return;
650        }
651
652        $policyKeyByConfigKey = array_flip($configKeyByPolicy);
653        $allowOverrideConfigKeys = array_map(
654            fn (string $configKey): string => $this->getSystemAllowOverrideConfigKey($configKey),
655            array_values($configKeyByPolicy),
656        );
657
658        $systemConfigQuery = $this->db->getQueryBuilder();
659        $systemConfigQuery->select('configkey', 'configvalue')
660            ->from('appconfig')
661            ->where($systemConfigQuery->expr()->eq('appid', $systemConfigQuery->createNamedParameter(Application::APP_ID)))
662            ->andWhere($systemConfigQuery->expr()->in('configkey', $systemConfigQuery->createNamedParameter(
663                [...array_values($configKeyByPolicy), ...$allowOverrideConfigKeys],
664                IQueryBuilder::PARAM_STR_ARRAY,
665            )));
666
667        /** @var array<string, string> $storedValues */
668        $storedValues = [];
669        $systemConfigResult = $systemConfigQuery->executeQuery();
670        try {
671            while ($row = $systemConfigResult->fetchAssociative()) {
672                $storedValues[(string)$row['configkey']] = (string)($row['configvalue'] ?? '');
673            }
674        } finally {
675            $systemConfigResult->closeCursor();
676        }
677
678        foreach ($policyKeyByConfigKey as $configKey => $policyKey) {
679            if (!isset($counts[$policyKey]) || !array_key_exists($configKey, $storedValues)) {
680                continue;
681            }
682
683            // Same rule as loadSystemPolicy(): an empty value only counts when it
684            // was saved with its override flag; without it, it is a legacy value.
685            if ($storedValues[$configKey] === ''
686                && !array_key_exists($this->getSystemAllowOverrideConfigKey($configKey), $storedValues)) {
687                continue;
688            }
689
690            $counts[$policyKey]['everyoneCount'] = 1;
691        }
692    }
693
694    #[\Override]
695    public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void {
696        $definition = $this->registry->get($policyKey);
697        $this->writeSystemValue($definition->getAppConfigKey(), $definition->normalizeValue($value));
698        $this->appConfig->setAppValueString(
699            $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()),
700            $allowChildOverride ? '1' : '0',
701        );
702    }
703
704    #[\Override]
705    public function clearSystemPolicy(string $policyKey): void {
706        $definition = $this->registry->get($policyKey);
707        $configKey = $definition->getAppConfigKey();
708        $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($configKey);
709
710        if ($this->appConfig->hasAppKey($configKey)) {
711            $this->appConfig->deleteAppValue($configKey);
712        }
713
714        if ($this->appConfig->hasAppKey($allowOverrideConfigKey)) {
715            $this->appConfig->deleteAppValue($allowOverrideConfigKey);
716        }
717    }
718
719    private function readSystemValue(string $key, mixed $defaultValue): mixed {
720        try {
721            if (is_int($defaultValue)) {
722                return $this->appConfig->getAppValueInt($key, $defaultValue);
723            }
724
725            if (is_bool($defaultValue)) {
726                return $this->appConfig->getAppValueBool($key, $defaultValue);
727            }
728
729            if (is_float($defaultValue)) {
730                return $this->appConfig->getAppValueFloat($key, $defaultValue);
731            }
732
733            if (is_array($defaultValue)) {
734                return $this->appConfig->getAppValueArray($key, $defaultValue);
735            }
736
737            return $this->appConfig->getAppValueString($key, (string)$defaultValue);
738        } catch (AppConfigTypeConflictException $exception) {
739            if (is_string($defaultValue)) {
740                try {
741                    $arrayValue = $this->appConfig->getAppValueArray($key, []);
742                    return json_encode($arrayValue, JSON_THROW_ON_ERROR);
743                } catch (AppConfigTypeConflictException) {
744                    return $this->appConfig->getAppValueBool($key, in_array(strtolower(trim($defaultValue)), ['1', 'true', 'yes', 'on'], true));
745                } catch (\JsonException) {
746                    return (string)$defaultValue;
747                }
748            }
749
750            if (is_bool($defaultValue)) {
751                return $this->appConfig->getAppValueString($key, $defaultValue ? '1' : '0');
752            }
753
754            if (is_array($defaultValue)) {
755                // Value was stored as a scalar (e.g., by Nextcloud provisioning API).
756                // Return the raw string so the policy normalizer can decode it.
757                try {
758                    return $this->appConfig->getAppValueString($key, '');
759                } catch (AppConfigTypeConflictException) {
760                    return $defaultValue;
761                }
762            }
763
764            throw $exception;
765        }
766    }
767
768    private function writeSystemValue(string $key, mixed $value): void {
769        try {
770            $this->setSystemValueByType($key, $value);
771        } catch (AppConfigTypeConflictException) {
772            if ($this->appConfig->hasAppKey($key)) {
773                $this->appConfig->deleteAppValue($key);
774            }
775
776            $this->setSystemValueByType($key, $value);
777        }
778    }
779
780    private function setSystemValueByType(string $key, mixed $value): void {
781        if (is_int($value)) {
782            $this->appConfig->setAppValueInt($key, $value);
783            return;
784        }
785
786        if (is_bool($value)) {
787            $this->appConfig->setAppValueBool($key, $value);
788            return;
789        }
790
791        if (is_float($value)) {
792            $this->appConfig->setAppValueFloat($key, $value);
793            return;
794        }
795
796        if (is_array($value)) {
797            $this->appConfig->setAppValueArray($key, $value);
798            return;
799        }
800
801        $this->appConfig->setAppValueString($key, (string)$value);
802    }
803
804    /**
805     * Deserialize a raw appconfig value string based on the expected type.
806     * Does not perform any database queries - pure local deserialization.
807     */
808    private function deserializeStoredValueWithType(string $storedValueRaw, mixed $defaultValue): mixed {
809        if (is_int($defaultValue)) {
810            return (int)$storedValueRaw;
811        }
812
813        if (is_bool($defaultValue)) {
814            return in_array(strtolower(trim($storedValueRaw)), ['1', 'true', 'yes', 'on'], true);
815        }
816
817        if (is_float($defaultValue)) {
818            return (float)$storedValueRaw;
819        }
820
821        if (is_array($defaultValue)) {
822            try {
823                $decoded = json_decode($storedValueRaw, true, flags: JSON_THROW_ON_ERROR);
824                return is_array($decoded) ? $decoded : [];
825            } catch (\JsonException) {
826                return [];
827            }
828        }
829
830        return $storedValueRaw;
831    }
832
833    private function getSystemAllowOverrideConfigKey(string $policyConfigKey): string {
834        return $policyConfigKey . '.allow_child_override';
835    }
836
837    private function getAssignedUserPolicyKey(string $policyConfigKey): string {
838        return $policyConfigKey . '.assigned';
839    }
840
841    private function serializeStoredValue(mixed $value): string {
842        return json_encode($value, JSON_THROW_ON_ERROR);
843    }
844
845    private function deserializeStoredValue(string $value): mixed {
846        try {
847            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
848        } catch (\JsonException) {
849            return $value;
850        }
851    }
852
853    private function serializeStoredUserPolicyPayload(mixed $value, bool $allowChildOverride): string {
854        return json_encode([
855            'value' => $value,
856            'allowChildOverride' => $allowChildOverride,
857        ], JSON_THROW_ON_ERROR);
858    }
859
860    private function createUserPolicyLayerFromPayload(IPolicyDefinition $definition, string $storedPayload): ?PolicyLayer {
861        $decodedPayload = $this->deserializeStoredUserPolicyPayload($storedPayload);
862        if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) {
863            return null;
864        }
865
866        $normalizedValue = $definition->normalizeValue($decodedPayload['value']);
867        $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false);
868
869        return (new PolicyLayer())
870            ->setScope('user_policy')
871            ->setValue($normalizedValue)
872            ->setAllowChildOverride($allowChildOverride)
873            ->setVisibleToChild(true)
874            ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]);
875    }
876
877    private function deserializeStoredUserPolicyPayload(string $payload): mixed {
878        try {
879            return json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
880        } catch (\JsonException) {
881            return null;
882        }
883    }
884
885    /** @param array<string, mixed> $policyJson */
886    private function resolveStoredGroupPolicyConfig(array $policyJson, string $policyKey): ?array {
887        $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey);
888        $delegatedOverride = $policyJson[$overrideKey] ?? null;
889        if ($this->hasExplicitGroupPolicyConfig($delegatedOverride)) {
890            $definition = $this->registry->get($policyKey);
891            if ($definition->supportsGroupAdminDelegation()) {
892                $resolvedOverride = $delegatedOverride;
893                if ($this->isGroupPolicyConfigCreatedBySystemAdmin($policyJson[$policyKey] ?? null)) {
894                    $resolvedOverride['delegatedFromSystemCreatedSeed'] = true;
895                }
896
897                return $resolvedOverride;
898            }
899        }
900
901        $policyConfig = $policyJson[$policyKey] ?? null;
902        return $this->hasExplicitGroupPolicyConfig($policyConfig) ? $policyConfig : null;
903    }
904
905    private function hasExplicitGroupPolicyConfig(mixed $policyConfig): bool {
906        return is_array($policyConfig)
907            && array_key_exists('defaultValue', $policyConfig)
908            && $policyConfig['defaultValue'] !== null;
909    }
910
911    private function isGroupPolicyConfigCreatedBySystemAdmin(mixed $policyConfig): bool {
912        if (!is_array($policyConfig)) {
913            return false;
914        }
915
916        return $this->resolveCreatedBySystemAdmin($policyConfig);
917    }
918
919    private function buildStoredGroupPolicyConfig(mixed $normalizedValue, bool $allowChildOverride, bool $createdBySystemAdmin): array {
920        return [
921            'defaultValue' => $normalizedValue,
922            'allowChildOverride' => $allowChildOverride,
923            'visibleToChild' => true,
924            'allowedValues' => $allowChildOverride ? [] : [$normalizedValue],
925            'createdBySystemAdmin' => $createdBySystemAdmin,
926            'createdByActorScope' => $createdBySystemAdmin ? 'system' : 'group',
927        ];
928    }
929
930    /**
931     * @param array<string, mixed> $policyJson
932     * @param array<string, array{groupCount: int, userCount: int, everyoneCount: int}> $counts
933     * @param array<string, string> $parentPolicyKeys
934     */
935    private function incrementGroupRuleCounts(array &$counts, array $policyJson, array $parentPolicyKeys): void {
936        /** @var array<string, true> $ruleKeys */
937        $ruleKeys = [];
938        foreach ($policyJson as $policyKey => $policyConfig) {
939            $baseKey = $this->tryResolveDelegatedOverrideBaseKey($policyKey);
940            if ($baseKey !== null) {
941                if (
942                    isset($counts[$baseKey])
943                    && !$this->hasExplicitGroupPolicyConfig($policyJson[$baseKey] ?? null)
944                    && $this->hasExplicitGroupPolicyConfig($policyConfig)
945                ) {
946                    $ruleKeys[$baseKey] = true;
947                }
948                continue;
949            }
950
951            if (!isset($counts[$policyKey]) || !$this->hasExplicitGroupPolicyConfig($policyConfig)) {
952                continue;
953            }
954
955            $ruleKeys[$policyKey] = true;
956        }
957
958        foreach (array_keys($ruleKeys) as $policyKey) {
959            $parentKey = $parentPolicyKeys[$policyKey] ?? null;
960            if ($parentKey !== null) {
961                $ruleKeys[$parentKey] = true;
962            }
963        }
964
965        foreach (array_keys($ruleKeys) as $policyKey) {
966            $counts[$policyKey]['groupCount']++;
967        }
968    }
969
970    /**
971     * @param array<string, mixed> $policyJson
972     */
973    private function persistGroupPermissionSet(PermissionSet $permissionSet, string $groupId, array $policyJson, \DateTime $now): void {
974        $permissionSet->setPolicyJson($policyJson);
975        $permissionSet->setUpdatedAt($now);
976
977        if ($permissionSet->getId() > 0) {
978            $this->permissionSetMapper->update($permissionSet);
979            return;
980        }
981
982        /** @var PermissionSet $permissionSet */
983        $permissionSet = $this->permissionSetMapper->insert($permissionSet);
984
985        $binding = new PermissionSetBinding();
986        $binding->setPermissionSetId($permissionSet->getId());
987        $binding->setTargetType('group');
988        $binding->setTargetId($groupId);
989        $binding->setCreatedAt($now);
990
991        $this->bindingMapper->insert($binding);
992    }
993
994    #[\Override]
995    public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride, bool $createdBySystemAdmin = false, ?PolicyContext $context = null): void {
996        $definition = $this->registry->get($policyKey);
997        $normalizedValue = $definition->normalizeValue($value);
998        $permissionSet = $this->findPermissionSetByGroupId($groupId);
999        $now = new \DateTime('now', new \DateTimeZone('UTC'));
1000
1001        if (!$permissionSet instanceof PermissionSet) {
1002            $permissionSet = new PermissionSet();
1003            $permissionSet->setName('group:' . $groupId);
1004            $permissionSet->setScopeType('group');
1005            $permissionSet->setCreatedAt($now);
1006        }
1007
1008        $policyJson = $permissionSet->getDecodedPolicyJson();
1009
1010        if ($definition->supportsGroupAdminDelegation()) {
1011            $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey);
1012            $existingSystemCreatedSeed = $this->isGroupPolicyConfigCreatedBySystemAdmin($policyJson[$policyKey] ?? null);
1013
1014            if ($existingSystemCreatedSeed && !$createdBySystemAdmin) {
1015                $seedRaw = $policyJson[$policyKey];
1016                $effectiveContext = $context ?? new PolicyContext();
1017                $definition->validateGroupAdminDelegatedValue(
1018                    $normalizedValue,
1019                    $definition->normalizeValue($seedRaw['defaultValue'] ?? null),
1020                    $effectiveContext,
1021                );
1022                $policyJson[$overrideKey] = $this->buildStoredGroupPolicyConfig($normalizedValue, $allowChildOverride, false);
1023                $this->persistGroupPermissionSet($permissionSet, $groupId, $policyJson, $now);
1024                return;
1025            }
1026
1027            // When a system admin saves, clear any stale delegated override.
1028            unset($policyJson[$overrideKey]);
1029        }
1030
1031        $policyJson[$policyKey] = $this->buildStoredGroupPolicyConfig(
1032            $normalizedValue,
1033            $allowChildOverride,
1034            $createdBySystemAdmin,
1035        );
1036
1037        $this->persistGroupPermissionSet($permissionSet, $groupId, $policyJson, $now);
1038    }
1039
1040    #[\Override]
1041    public function clearGroupPolicy(string $policyKey, string $groupId, bool $preserveSystemCreatedBase = false): void {
1042        $binding = $this->findBindingByGroupId($groupId);
1043        if (!$binding instanceof PermissionSetBinding) {
1044            return;
1045        }
1046
1047        $permissionSet = $this->findPermissionSetByBinding($binding);
1048        if (!$permissionSet instanceof PermissionSet) {
1049            return;
1050        }
1051
1052        $policyJson = $permissionSet->getDecodedPolicyJson();
1053        $definition = $this->registry->get($policyKey);
1054        if ($definition->supportsGroupAdminDelegation()) {
1055            $overrideKey = $this->getDelegatedGroupAdminOverrideKey($policyKey);
1056            $seedConfig = $policyJson[$policyKey] ?? null;
1057            $delegatedOverrideConfig = $policyJson[$overrideKey] ?? null;
1058            $shouldPreserveSystemSeed = $preserveSystemCreatedBase
1059                && $this->isGroupPolicyConfigCreatedBySystemAdmin($seedConfig)
1060                && $this->hasExplicitGroupPolicyConfig($delegatedOverrideConfig)
1061                && !$this->isGroupPolicyConfigCreatedBySystemAdmin($delegatedOverrideConfig);
1062
1063            unset($policyJson[$overrideKey]);
1064            if (!$shouldPreserveSystemSeed) {
1065                unset($policyJson[$policyKey]);
1066            }
1067        } else {
1068            unset($policyJson[$policyKey]);
1069        }
1070
1071        if ($policyJson === []) {
1072            $this->bindingMapper->delete($binding);
1073            $this->permissionSetMapper->delete($permissionSet);
1074            return;
1075        }
1076
1077        $permissionSet->setPolicyJson($policyJson);
1078        $permissionSet->setUpdatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
1079        $this->permissionSetMapper->update($permissionSet);
1080    }
1081
1082    #[\Override]
1083    public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void {
1084        $userId = $context->getUserId();
1085        if ($userId === null || $userId === '') {
1086            // TRANSLATORS Error shown when saving a LibreSign policy preference without a signed-in user.
1087            throw new \InvalidArgumentException($this->l10n->t('A signed-in user is required to save a policy preference.'));
1088        }
1089
1090        $definition = $this->registry->get($policyKey);
1091        $normalizedValue = $definition->normalizeValue($value);
1092        $this->appConfig->setUserValue($userId, $definition->getUserPreferenceKey(), $this->serializeStoredValue($normalizedValue));
1093    }
1094
1095    #[\Override]
1096    public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void {
1097        $userId = $context->getUserId();
1098        if ($userId === null || $userId === '') {
1099            // TRANSLATORS Error shown when saving a user-scoped LibreSign policy without a target user.
1100            throw new \InvalidArgumentException($this->l10n->t('A target user is required to save a user policy.'));
1101        }
1102
1103        $definition = $this->registry->get($policyKey);
1104        $normalizedValue = $definition->normalizeValue($value);
1105        $this->appConfig->setUserValue(
1106            $userId,
1107            $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()),
1108            $this->serializeStoredUserPolicyPayload($normalizedValue, $allowChildOverride),
1109        );
1110    }
1111
1112    #[\Override]
1113    public function clearUserPreference(string $policyKey, PolicyContext $context): void {
1114        $userId = $context->getUserId();
1115        if ($userId === null || $userId === '') {
1116            return;
1117        }
1118
1119        $definition = $this->registry->get($policyKey);
1120        $this->appConfig->deleteUserValue($userId, $definition->getUserPreferenceKey());
1121    }
1122
1123    #[\Override]
1124    public function clearUserPolicy(string $policyKey, PolicyContext $context): void {
1125        $userId = $context->getUserId();
1126        if ($userId === null || $userId === '') {
1127            return;
1128        }
1129
1130        $definition = $this->registry->get($policyKey);
1131        $this->appConfig->deleteUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()));
1132    }
1133
1134    /** @return list<string> */
1135    private function resolveGroupIds(PolicyContext $context): array {
1136        $scope = $context->getActiveGroupScope();
1137        if ($scope !== null) {
1138            return [$scope->groupId];
1139        }
1140
1141        return $context->getGroups();
1142    }
1143
1144    /** @param array<string, mixed> $policyConfig */
1145    private function createGroupPolicyLayer(array $policyConfig): PolicyLayer {
1146        $createdBySystemAdmin = $this->resolveCreatedBySystemAdmin($policyConfig);
1147        $delegatedFromSystemCreatedSeed = isset($policyConfig['delegatedFromSystemCreatedSeed'])
1148            && (bool)$policyConfig['delegatedFromSystemCreatedSeed'];
1149
1150        $layer = (new PolicyLayer())
1151            ->setScope('group')
1152            ->setValue($policyConfig['defaultValue'] ?? null)
1153            ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false))
1154            ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true))
1155            ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : [])
1156            ->setCreatedBySystemAdmin($createdBySystemAdmin)
1157            ->setDelegatedFromSystemCreatedSeed($delegatedFromSystemCreatedSeed);
1158
1159        return $layer;
1160    }
1161
1162    private function resolveCreatedBySystemAdmin(array $policyConfig): bool {
1163        $createdBySystemAdmin = $policyConfig['createdBySystemAdmin'] ?? null;
1164        if (is_bool($createdBySystemAdmin)) {
1165            return $createdBySystemAdmin;
1166        }
1167
1168        return ($policyConfig['createdByActorScope'] ?? null) === 'system';
1169    }
1170
1171    private function getDelegatedGroupAdminOverrideKey(string $policyKey): string {
1172        return $policyKey . '__delegated_override';
1173    }
1174
1175    private function tryResolveDelegatedOverrideBaseKey(string $key): ?string {
1176        $suffix = '__delegated_override';
1177        if (!str_ends_with($key, $suffix)) {
1178            return null;
1179        }
1180
1181        $baseKey = substr($key, 0, -strlen($suffix));
1182        try {
1183            $definition = $this->registry->get($baseKey);
1184            return $definition->supportsGroupAdminDelegation() ? $baseKey : null;
1185        } catch (\InvalidArgumentException) {
1186            return null;
1187        }
1188    }
1189
1190    private function findBindingByGroupId(string $groupId): ?PermissionSetBinding {
1191        try {
1192            return $this->bindingMapper->getByTarget('group', $groupId);
1193        } catch (DoesNotExistException) {
1194            return null;
1195        }
1196    }
1197
1198    private function findPermissionSetByBinding(PermissionSetBinding $binding): ?PermissionSet {
1199        try {
1200            return $this->permissionSetMapper->getById($binding->getPermissionSetId());
1201        } catch (DoesNotExistException) {
1202            return null;
1203        }
1204    }
1205
1206    private function findPermissionSetByGroupId(string $groupId): ?PermissionSet {
1207        $binding = $this->findBindingByGroupId($groupId);
1208        if (!$binding instanceof PermissionSetBinding) {
1209            return null;
1210        }
1211
1212        return $this->findPermissionSetByBinding($binding);
1213    }
1214}