Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.23% covered (success)
96.23%
255 / 265
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultPolicyResolver
96.23% covered (success)
96.23%
255 / 265
50.00% covered (danger)
50.00%
6 / 12
88
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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 resolveCore
97.66% covered (success)
97.66%
125 / 128
0.00% covered (danger)
0.00%
0 / 1
32
 buildResolvedStateMeta
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 applyValueChoiceGroupLayers
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
12.05
 resolveMany
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 applyLayer
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 canApplyLowerLayer
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 mergeAllowedValues
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resolveValueChoiceLayerChoices
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 mergeUnionAllowedValues
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
8.09
 pickValueChoiceDefault
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
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\Service\Policy\Contract\IPolicyDefinition;
12use OCA\Libresign\Service\Policy\Contract\IPolicyResolver;
13use OCA\Libresign\Service\Policy\Contract\IPolicySource;
14use OCA\Libresign\Service\Policy\Model\PolicyContext;
15use OCA\Libresign\Service\Policy\Model\PolicyLayer;
16use OCA\Libresign\Service\Policy\Model\PolicySpec;
17use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
18
19final class DefaultPolicyResolver implements IPolicyResolver {
20    public function __construct(
21        private IPolicySource $source,
22    ) {
23    }
24
25    #[\Override]
26    public function resolve(IPolicyDefinition $definition, PolicyContext $context): ResolvedPolicy {
27        return $this->resolveCore(
28            $definition,
29            $context,
30            $this->source->loadGroupPolicies($definition->key(), $context),
31            $this->source->loadUserPolicy($definition->key(), $context),
32            $this->source->loadUserPreference($definition->key(), $context),
33        );
34    }
35
36    /**
37     * @param list<PolicyLayer> $groupLayers Pre-fetched group layers (avoids repeat DB calls in bulk resolution)
38     */
39    private function resolveCore(
40        IPolicyDefinition $definition,
41        PolicyContext $context,
42        array $groupLayers,
43        ?PolicyLayer $userPolicy,
44        ?PolicyLayer $userPreference,
45    ): ResolvedPolicy {
46        $policyKey = $definition->key();
47        $resolved = (new ResolvedPolicy())
48            ->setPolicyKey($policyKey)
49            ->setAllowedValues($definition->allowedValues($context));
50
51        $systemLayer = $this->source->loadSystemPolicy($policyKey);
52
53        $currentValue = $definition->defaultSystemValue();
54        $currentSourceScope = 'system';
55        $currentBlockedBy = null;
56        $canOverrideBelow = false;
57        $visible = true;
58        $actorRole = $context->getActorRole();
59        $currentActorCanManageSystemPolicies = $actorRole->canManageSystemPolicies;
60        $currentActorCanManageGroupPolicies = $actorRole->canManageGroupPolicies && $actorRole->manageableGroupCount > 0;
61        // Lower-level rule creation is closed by default for implicit system defaults,
62        // but remains available when the system explicitly delegates downward.
63        $isSystemExplicitlyGrantedForDescendantRules = $systemLayer !== null
64            && $systemLayer->getScope() === 'global'
65            && $systemLayer->isAllowChildOverride();
66
67        if ($systemLayer !== null) {
68            [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer(
69                $definition,
70                $resolved,
71                $systemLayer,
72                $context,
73                $currentValue,
74                $currentSourceScope,
75                true,
76                $visible,
77            );
78        }
79
80        if ($definition->resolutionMode() === 'value_choice') {
81            [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyValueChoiceGroupLayers(
82                $definition,
83                $resolved,
84                $groupLayers,
85                $context,
86                $currentValue,
87                $currentSourceScope,
88                $canOverrideBelow,
89                $visible,
90            );
91        } else {
92            foreach ($groupLayers as $layer) {
93                [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer(
94                    $definition,
95                    $resolved,
96                    $layer,
97                    $context,
98                    $currentValue,
99                    $currentSourceScope,
100                    $canOverrideBelow,
101                    $visible,
102                );
103            }
104        }
105
106        if ($userPolicy !== null) {
107            $canOverrideAtGroupScope = $canOverrideBelow;
108            $hasConfiguredGroupLayer = $groupLayers !== [];
109            [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer(
110                $definition,
111                $resolved,
112                $userPolicy,
113                $context,
114                $currentValue,
115                $currentSourceScope,
116                $canOverrideBelow,
117                $visible,
118            );
119        } else {
120            $canOverrideAtGroupScope = $canOverrideBelow;
121            $hasConfiguredGroupLayer = $groupLayers !== [];
122        }
123
124        $inheritedValue = $currentValue;
125
126        if ($userPreference !== null) {
127            if (!$definition->supportsUserPreference()) {
128                $this->source->clearUserPreference($policyKey, $context);
129                $resolved->setPreferenceWasCleared(true);
130            } elseif ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible, $context)) {
131                $currentValue = $definition->normalizeValue($userPreference->getValue());
132                $definition->validateValue($currentValue, $context);
133                $currentSourceScope = $userPreference->getScope();
134            } else {
135                $this->source->clearUserPreference($policyKey, $context);
136                $currentBlockedBy = $currentSourceScope;
137                $resolved->setPreferenceWasCleared(true);
138            }
139        }
140
141        $canActorOverrideBelowUserScope = $definition->supportsScope(PolicySpec::SCOPE_USER)
142            && $visible
143            && $canOverrideBelow
144            && (
145                $currentActorCanManageSystemPolicies
146                || $isSystemExplicitlyGrantedForDescendantRules
147                || $hasConfiguredGroupLayer
148            );
149        $canUseAsRequestOverride = $canActorOverrideBelowUserScope && $definition->supportsRequestOverride();
150
151        $requestOverride = $this->source->loadRequestOverride($policyKey, $context);
152        if ($requestOverride !== null) {
153            // Checked here, not only by the file policy appliers. A value the current
154            // actor submits needs the actor's permission; a value the document stored
155            // earlier was authorized when it was submitted, so replaying it does not
156            // depend on who resolves it now.
157            $isRequestOverrideAuthorized = $context->isStoredRequestOverride($policyKey)
158                ? $definition->supportsRequestOverride()
159                : $canUseAsRequestOverride;
160            if ($isRequestOverrideAuthorized
161                && $this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible, $context)
162            ) {
163                $currentValue = $definition->normalizeValue($requestOverride->getValue());
164                $definition->validateValue($currentValue, $context);
165                $currentSourceScope = $requestOverride->getScope();
166            } elseif ($currentBlockedBy === null) {
167                $currentBlockedBy = $currentSourceScope;
168            }
169        }
170
171        $canCreateDescendantRules = (
172            $definition->supportsScope(PolicySpec::SCOPE_GROUP)
173            || $definition->supportsScope(PolicySpec::SCOPE_USER)
174        )
175            && $visible
176            && $canOverrideBelow
177            && (
178                $currentActorCanManageSystemPolicies
179                || (
180                    $currentActorCanManageGroupPolicies
181                    && (
182                        $isSystemExplicitlyGrantedForDescendantRules
183                        || $hasConfiguredGroupLayer
184                    )
185                )
186            );
187
188        $resolved
189            ->setMeta($this->buildResolvedStateMeta($definition, $context, $canCreateDescendantRules))
190            ->setEffectiveValue($currentValue)
191            ->setInheritedValue($inheritedValue)
192            ->setSourceScope($currentSourceScope)
193            ->setVisible($visible)
194            ->setEditableByCurrentActor(
195                $visible
196                && $definition->supportsScope(PolicySpec::SCOPE_GROUP)
197                && $definition->canCurrentActorManageGroupPolicy($context, $systemLayer, $groupLayers)
198            )
199            ->setCanSaveAsUserDefault($canActorOverrideBelowUserScope && $definition->supportsUserPreference())
200            ->setCanUseAsRequestOverride($canUseAsRequestOverride)
201            ->setBlockedBy($currentBlockedBy);
202
203        return $resolved;
204    }
205
206    /** @return array<string, mixed> */
207    private function buildResolvedStateMeta(IPolicyDefinition $definition, PolicyContext $context, bool $canCreateDescendantRules): array {
208        $meta = [
209            'appConfigKey' => $definition->getAppConfigKey(),
210            'userPreferenceKey' => $definition->getUserPreferenceKey(),
211            'defaultSystemValue' => $definition->defaultSystemValue(),
212            'resolutionMode' => $definition->resolutionMode(),
213            'supportsUserPreference' => $definition->supportsUserPreference(),
214            'supportsGroupAdminDelegation' => $definition->supportsGroupAdminDelegation(),
215            'supportedScopes' => $definition->supportedScopes(),
216            'backendOnly' => $definition->isBackendOnly(),
217            'helper' => $definition->isHelper(),
218        ];
219
220        if ($definition->parentPolicyKey() !== null) {
221            $meta['parentPolicyKey'] = $definition->parentPolicyKey();
222        }
223
224        if ($definition->compositeChildren() !== []) {
225            $meta['compositeChildren'] = $definition->compositeChildren();
226        }
227
228        $meta = array_merge($meta, $definition->resolvedStateMeta($context));
229
230        if ($canCreateDescendantRules) {
231            $meta['canCreateDescendantRules'] = true;
232        }
233
234        return $meta;
235    }
236
237    /**
238     * @param list<PolicyLayer> $layers
239     * @return array{0: mixed, 1: string, 2: bool, 3: bool}
240     */
241    private function applyValueChoiceGroupLayers(
242        IPolicyDefinition $definition,
243        ResolvedPolicy $resolved,
244        array $layers,
245        PolicyContext $context,
246        mixed $currentValue,
247        string $currentSourceScope,
248        bool $canOverrideBelow,
249        bool $visible,
250    ): array {
251        if ($layers === [] || !$visible || !$canOverrideBelow) {
252            return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible];
253        }
254
255        $upstreamAllowedValues = $resolved->getAllowedValues();
256        $combinedChoices = [];
257        $groupDefaultValues = [];
258        $hasVisibleLayer = false;
259
260        foreach ($layers as $layer) {
261            if (!$layer->isVisibleToChild()) {
262                continue;
263            }
264
265            $hasVisibleLayer = true;
266            $layerChoices = $this->resolveValueChoiceLayerChoices($definition, $layer, $upstreamAllowedValues, $context);
267            $combinedChoices = $this->mergeUnionAllowedValues(
268                $definition->allowedValues($context),
269                $combinedChoices,
270                $layerChoices,
271            );
272
273            $normalizedDefault = $definition->normalizeValue($layer->getValue());
274            if ($layer->getValue() !== null && in_array($normalizedDefault, $combinedChoices, true) && !in_array($normalizedDefault, $groupDefaultValues, true)) {
275                $groupDefaultValues[] = $normalizedDefault;
276            }
277        }
278
279        if (!$hasVisibleLayer || $combinedChoices === []) {
280            return [$currentValue, $currentSourceScope, false, $visible && $hasVisibleLayer];
281        }
282
283        $resolved->setAllowedValues($combinedChoices);
284
285        return [
286            $this->pickValueChoiceDefault($definition, $currentValue, $combinedChoices, $groupDefaultValues, $context),
287            'group',
288            count($combinedChoices) > 1,
289            true,
290        ];
291    }
292
293    #[\Override]
294    /** @param list<IPolicyDefinition> $definitions */
295    public function resolveMany(array $definitions, PolicyContext $context): array {
296        $validDefinitions = array_filter(
297            $definitions,
298            static fn (mixed $d): bool => $d instanceof IPolicyDefinition,
299        );
300
301        $policyKeys = array_map(
302            static fn (IPolicyDefinition $d): string => $d->key(),
303            $validDefinitions,
304        );
305
306        $allGroupLayers = $this->source->loadAllGroupPolicies($policyKeys, $context);
307        $allUserPolicies = $this->source->loadAllUserPolicies($policyKeys, $context);
308        $allUserPrefs = $this->source->loadAllUserPreferences($policyKeys, $context);
309
310        $resolved = [];
311        foreach ($validDefinitions as $definition) {
312            $key = $definition->key();
313            $resolved[$key] = $this->resolveCore(
314                $definition,
315                $context,
316                $allGroupLayers[$key] ?? [],
317                $allUserPolicies[$key] ?? null,
318                $allUserPrefs[$key] ?? null,
319            );
320        }
321
322        return $resolved;
323    }
324
325    private function applyLayer(
326        IPolicyDefinition $definition,
327        ResolvedPolicy $resolved,
328        PolicyLayer $layer,
329        PolicyContext $context,
330        mixed $currentValue,
331        string $currentSourceScope,
332        bool $canOverrideBelow,
333        bool $visible,
334    ): array {
335        $visible = $visible && $layer->isVisibleToChild();
336        $resolved->setAllowedValues($this->mergeAllowedValues($resolved->getAllowedValues(), $layer->getAllowedValues()));
337
338        if ($layer->getValue() !== null && $canOverrideBelow) {
339            $normalized = $definition->normalizeValue($layer->getValue());
340            try {
341                $definition->validateValue($normalized, $context);
342            } catch (\InvalidArgumentException) {
343                // Stored config value is invalid (e.g. leftover from an unconfigured
344                // or migrated state). Skip this layer and keep the current/default value.
345                $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride();
346                return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible];
347            }
348            $currentValue = $normalized;
349            $currentSourceScope = $layer->getScope();
350        }
351
352        $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride();
353
354        return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible];
355    }
356
357    private function canApplyLowerLayer(
358        IPolicyDefinition $definition,
359        ResolvedPolicy $resolved,
360        PolicyLayer $layer,
361        bool $canOverrideBelow,
362        bool $visible,
363        PolicyContext $context,
364    ): bool {
365        if (!$visible || !$canOverrideBelow || $layer->getValue() === null) {
366            return false;
367        }
368
369        $value = $definition->normalizeValue($layer->getValue());
370        $allowedValues = $resolved->getAllowedValues();
371        if ($allowedValues !== [] && !in_array($value, $allowedValues, true)) {
372            return false;
373        }
374
375        $definition->validateValue($value, $context);
376        return true;
377    }
378
379    /** @param list<mixed> $currentAllowedValues
380     * @param list<mixed> $layerAllowedValues
381     * @return list<mixed>
382     */
383    private function mergeAllowedValues(array $currentAllowedValues, array $layerAllowedValues): array {
384        if ($layerAllowedValues === []) {
385            return $currentAllowedValues;
386        }
387
388        if ($currentAllowedValues === []) {
389            return $layerAllowedValues;
390        }
391
392        return array_values(array_intersect($currentAllowedValues, $layerAllowedValues));
393    }
394
395    /**
396     * @param list<mixed> $upstreamAllowedValues
397     * @return list<mixed>
398     */
399    private function resolveValueChoiceLayerChoices(
400        IPolicyDefinition $definition,
401        PolicyLayer $layer,
402        array $upstreamAllowedValues,
403        PolicyContext $context,
404    ): array {
405        if ($layer->isAllowChildOverride()) {
406            $choices = $layer->getAllowedValues() === []
407                ? $upstreamAllowedValues
408                : array_values(array_intersect($upstreamAllowedValues, $layer->getAllowedValues()));
409
410            if ($definition->retainDefaultInGroupChoices()) {
411                return $choices;
412            }
413
414            $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
415            return array_values(array_filter(
416                $choices,
417                static fn (mixed $choice): bool => $choice !== $defaultValue,
418            ));
419        }
420
421        if ($layer->getValue() === null) {
422            return [];
423        }
424
425        $value = $definition->normalizeValue($layer->getValue());
426        if ($upstreamAllowedValues !== [] && !in_array($value, $upstreamAllowedValues, true)) {
427            return [];
428        }
429
430        $definition->validateValue($value, $context);
431        return [$value];
432    }
433
434    /**
435     * @param list<mixed> $canonicalOrder
436     * @param list<mixed> $currentValues
437     * @param list<mixed> $newValues
438     * @return list<mixed>
439     */
440    private function mergeUnionAllowedValues(array $canonicalOrder, array $currentValues, array $newValues): array {
441        $merged = [];
442        foreach ($canonicalOrder as $candidate) {
443            if ((in_array($candidate, $currentValues, true) || in_array($candidate, $newValues, true)) && !in_array($candidate, $merged, true)) {
444                $merged[] = $candidate;
445            }
446        }
447
448        foreach ([$currentValues, $newValues] as $values) {
449            foreach ($values as $candidate) {
450                if (!in_array($candidate, $merged, true)) {
451                    $merged[] = $candidate;
452                }
453            }
454        }
455
456        return $merged;
457    }
458
459    /**
460     * @param list<mixed> $allowedValues
461     * @param list<mixed> $groupDefaultValues
462     */
463    private function pickValueChoiceDefault(
464        IPolicyDefinition $definition,
465        mixed $currentValue,
466        array $allowedValues,
467        array $groupDefaultValues,
468        PolicyContext $context,
469    ): mixed {
470        $normalizedCurrentValue = $definition->normalizeValue($currentValue);
471        $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
472
473        if (count($groupDefaultValues) === 1 && in_array($groupDefaultValues[0], $allowedValues, true)) {
474            return $groupDefaultValues[0];
475        }
476
477        if ($normalizedCurrentValue !== $defaultValue && in_array($normalizedCurrentValue, $allowedValues, true)) {
478            return $normalizedCurrentValue;
479        }
480
481        $orderedAllowedValues = $this->mergeUnionAllowedValues($definition->allowedValues($context), [], $allowedValues);
482        return $orderedAllowedValues[0] ?? $normalizedCurrentValue;
483    }
484}