Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
66 / 68
92.86% covered (success)
92.86%
26 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
PolicySpec
97.06% covered (success)
97.06%
66 / 68
92.86% covered (success)
92.86%
26 / 28
45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolutionMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 retainDefaultInGroupChoices
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportedScopes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsScope
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAppConfigKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserPreferenceKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isBackendOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHelper
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parentPolicyKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compositeChildren
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 validateValueForPersistence
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateCompositeValuesForPersistence
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 allowedValues
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 defaultSystemValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolvedStateMeta
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 supportsUserPreference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsRequestOverride
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestLifecycle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldFilterVisibleGroupCountsForActor
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 canCurrentActorManageGroupPolicy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 canCurrentActorEditSystemCreatedGroupPolicy
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 hasExplicitSystemGroupManagementDelegation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 supportsGroupAdminDelegation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateGroupAdminDelegatedValue
100.00% covered (success)
100.00%
2 / 2
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\Model;
10
11use Closure;
12use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
13use function in_array;
14use function sprintf;
15
16final class PolicySpec implements IPolicyDefinition {
17    public const RESOLUTION_MODE_RESOLVED = 'resolved';
18    public const RESOLUTION_MODE_VALUE_CHOICE = 'value_choice';
19    public const SCOPE_SYSTEM = 'system';
20    public const SCOPE_GROUP = 'group';
21    public const SCOPE_USER = 'user';
22    public const LIFECYCLE_RUNTIME = 'runtime';
23    public const LIFECYCLE_REQUEST_SNAPSHOT = 'request_snapshot';
24
25    /** @var list<mixed>|Closure(PolicyContext): list<mixed> */
26    private array|Closure $allowedValuesResolver;
27    /** @var Closure(mixed): mixed|null */
28    private ?Closure $normalizer;
29    /** @var Closure(mixed, PolicyContext): void|null */
30    private ?Closure $validator;
31    /** @var Closure(mixed, PolicyContext): void|null */
32    private ?Closure $persistenceValidator;
33    /** @var Closure(array<string, mixed>, list<string>, PolicyContext): void|null */
34    private ?Closure $compositeValidator;
35    /** @var array<string, mixed>|Closure(PolicyContext): array<string, mixed> */
36    private array|Closure $resolvedStateMetaResolver;
37    /** @var Closure(PolicyContext, ?PolicyLayer): bool|null */
38    private ?Closure $visibleGroupCountFilterResolver;
39    /** @var Closure(PolicyContext, ?PolicyLayer, array<array-key, PolicyLayer>): bool|null */
40    private ?Closure $groupPolicyManagerResolver;
41    /** @var Closure(PolicyContext, ?PolicyLayer, PolicyLayer): bool|null */
42    private ?Closure $systemCreatedGroupRuleEditorResolver;
43    /** @var Closure(mixed, mixed, PolicyContext): void|null */
44    private ?Closure $delegatedValidator;
45
46    /**
47     * @param list<mixed>|Closure(PolicyContext): list<mixed> $allowedValues
48     * @param bool $supportsRequestOverride No default on purpose: every policy decides it, independently of $supportsUserPreference
49     * @param self::LIFECYCLE_* $requestLifecycle No default on purpose: every policy decides whether a sent request keeps its value
50     * @param Closure(mixed): mixed|null $normalizer
51     * @param Closure(mixed, PolicyContext): void|null $validator
52     * @param Closure(mixed, PolicyContext): void|null $persistenceValidator Extra checks applied only when a value is saved
53     * @param Closure(array<string, mixed>, list<string>, PolicyContext): void|null $compositeValidator Checks applied to the whole composite family before any key is saved
54     * @param array<string, mixed>|Closure(PolicyContext): array<string, mixed> $resolvedStateMeta
55     * @param Closure(mixed, mixed, PolicyContext): void|null $delegatedValueValidator
56     * @param Closure(PolicyContext, ?PolicyLayer): bool|null $visibleGroupCountFilter
57     * @param Closure(PolicyContext, ?PolicyLayer, array<array-key, PolicyLayer>): bool|null $groupPolicyManager
58     * @param Closure(PolicyContext, ?PolicyLayer, PolicyLayer): bool|null $systemCreatedGroupRuleEditor
59     * @param list<string> $supportedScopes
60     * @param list<string> $compositeChildren
61     * @param bool $retainDefaultInGroupChoices Keep valid defaults in delegated value-choice group options; false preserves sentinel semantics
62     */
63    public function __construct(
64        private string $key,
65        private mixed $defaultSystemValue,
66        array|Closure $allowedValues,
67        private bool $supportsRequestOverride,
68        private string $requestLifecycle,
69        ?Closure $normalizer = null,
70        ?Closure $validator = null,
71        private ?string $appConfigKey = null,
72        private string $resolutionMode = self::RESOLUTION_MODE_RESOLVED,
73        private bool $supportsUserPreference = true,
74        array|Closure $resolvedStateMeta = [],
75        ?Closure $visibleGroupCountFilter = null,
76        ?Closure $groupPolicyManager = null,
77        ?Closure $systemCreatedGroupRuleEditor = null,
78        private bool $supportsGroupAdminDelegation = false,
79        ?Closure $delegatedValueValidator = null,
80        private array $supportedScopes = [self::SCOPE_SYSTEM, self::SCOPE_GROUP, self::SCOPE_USER],
81        private bool $backendOnly = false,
82        private bool $helper = false,
83        private ?string $parentPolicyKey = null,
84        private array $compositeChildren = [],
85        ?Closure $persistenceValidator = null,
86        ?Closure $compositeValidator = null,
87        private bool $retainDefaultInGroupChoices = false,
88    ) {
89        $this->allowedValuesResolver = $allowedValues;
90        $this->normalizer = $normalizer;
91        $this->validator = $validator;
92        $this->persistenceValidator = $persistenceValidator;
93        $this->compositeValidator = $compositeValidator;
94        $this->resolvedStateMetaResolver = $resolvedStateMeta;
95        $this->visibleGroupCountFilterResolver = $visibleGroupCountFilter;
96        $this->groupPolicyManagerResolver = $groupPolicyManager;
97        $this->systemCreatedGroupRuleEditorResolver = $systemCreatedGroupRuleEditor;
98        $this->delegatedValidator = $delegatedValueValidator;
99    }
100
101    #[\Override]
102    public function key(): string {
103        return $this->key;
104    }
105
106    #[\Override]
107    public function resolutionMode(): string {
108        return $this->resolutionMode;
109    }
110
111    #[\Override]
112    public function retainDefaultInGroupChoices(): bool {
113        return $this->retainDefaultInGroupChoices;
114    }
115
116    /** @return list<string> */
117    #[\Override]
118    public function supportedScopes(): array {
119        return $this->supportedScopes;
120    }
121
122    #[\Override]
123    public function supportsScope(string $scope): bool {
124        return in_array($scope, $this->supportedScopes, true);
125    }
126
127    #[\Override]
128    public function getAppConfigKey(): string {
129        return $this->appConfigKey ?? $this->key;
130    }
131
132    #[\Override]
133    public function getUserPreferenceKey(): string {
134        return 'policy.' . $this->key;
135    }
136
137    #[\Override]
138    public function isBackendOnly(): bool {
139        return $this->backendOnly;
140    }
141
142    #[\Override]
143    public function isHelper(): bool {
144        return $this->helper;
145    }
146
147    #[\Override]
148    public function parentPolicyKey(): ?string {
149        return $this->parentPolicyKey;
150    }
151
152    /** @return list<string> */
153    #[\Override]
154    public function compositeChildren(): array {
155        return $this->compositeChildren;
156    }
157
158    #[\Override]
159    public function normalizeValue(mixed $rawValue): mixed {
160        if ($this->normalizer !== null) {
161            return ($this->normalizer)($rawValue);
162        }
163
164        return $rawValue;
165    }
166
167    #[\Override]
168    public function validateValue(mixed $value, PolicyContext $context): void {
169        if ($this->validator !== null) {
170            ($this->validator)($value, $context);
171            return;
172        }
173
174        // Empty allowedValues means "no explicit restriction" for this policy key.
175        if ($this->allowedValues($context) === []) {
176            return;
177        }
178
179        if (!in_array($value, $this->allowedValues($context), true)) {
180            throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key()));
181        }
182    }
183
184    #[\Override]
185    public function validateValueForPersistence(mixed $value, PolicyContext $context): void {
186        $this->validateValue($value, $context);
187        if ($this->persistenceValidator !== null) {
188            ($this->persistenceValidator)($value, $context);
189        }
190    }
191
192    #[\Override]
193    public function validateCompositeValuesForPersistence(array $normalizedValues, array $submittedKeys, PolicyContext $context): void {
194        if ($this->compositeValidator !== null) {
195            ($this->compositeValidator)($normalizedValues, $submittedKeys, $context);
196        }
197    }
198
199    #[\Override]
200    public function allowedValues(PolicyContext $context): array {
201        if ($this->allowedValuesResolver instanceof Closure) {
202            return ($this->allowedValuesResolver)($context);
203        }
204
205        return $this->allowedValuesResolver;
206    }
207
208    #[\Override]
209    public function defaultSystemValue(): mixed {
210        return $this->defaultSystemValue;
211    }
212
213    /** @return array<string, mixed> */
214    #[\Override]
215    public function resolvedStateMeta(PolicyContext $context): array {
216        if ($this->resolvedStateMetaResolver instanceof Closure) {
217            return ($this->resolvedStateMetaResolver)($context);
218        }
219
220        return $this->resolvedStateMetaResolver;
221    }
222
223    #[\Override]
224    public function supportsUserPreference(): bool {
225        return $this->supportsUserPreference;
226    }
227
228    #[\Override]
229    public function supportsRequestOverride(): bool {
230        return $this->supportsRequestOverride;
231    }
232
233    #[\Override]
234    public function requestLifecycle(): string {
235        return $this->requestLifecycle;
236    }
237
238    #[\Override]
239    public function shouldFilterVisibleGroupCountsForActor(PolicyContext $context, ?PolicyLayer $systemPolicy): bool {
240        if ($this->visibleGroupCountFilterResolver !== null) {
241            return ($this->visibleGroupCountFilterResolver)($context, $systemPolicy);
242        }
243
244        if ($context->getActorRole()->canManageSystemPolicies) {
245            return false;
246        }
247
248        return !$this->hasExplicitSystemGroupManagementDelegation($systemPolicy);
249    }
250
251    /** @param list<PolicyLayer> $groupLayers */
252    #[\Override]
253    public function canCurrentActorManageGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool {
254        if ($this->groupPolicyManagerResolver !== null) {
255            return ($this->groupPolicyManagerResolver)($context, $systemPolicy, $groupLayers);
256        }
257
258        $actorRole = $context->getActorRole();
259        if ($actorRole->canManageSystemPolicies) {
260            return true;
261        }
262
263        if (!$actorRole->canManageGroupPolicies) {
264            return false;
265        }
266
267        return $this->hasExplicitSystemGroupManagementDelegation($systemPolicy);
268    }
269
270    #[\Override]
271    public function canCurrentActorEditSystemCreatedGroupPolicy(PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool {
272        if ($this->systemCreatedGroupRuleEditorResolver !== null) {
273            return ($this->systemCreatedGroupRuleEditorResolver)($context, $systemPolicy, $existingPolicy);
274        }
275
276        return true;
277    }
278
279    private function hasExplicitSystemGroupManagementDelegation(?PolicyLayer $systemPolicy): bool {
280        return $systemPolicy instanceof PolicyLayer
281            && $systemPolicy->getScope() === 'global'
282            && $systemPolicy->isAllowChildOverride();
283    }
284
285    #[\Override]
286    public function supportsGroupAdminDelegation(): bool {
287        return $this->supportsGroupAdminDelegation;
288    }
289
290    #[\Override]
291    public function validateGroupAdminDelegatedValue(
292        mixed $proposedNormalizedValue,
293        mixed $parentSeedNormalizedValue,
294        PolicyContext $context,
295    ): void {
296        if ($this->delegatedValidator !== null) {
297            ($this->delegatedValidator)($proposedNormalizedValue, $parentSeedNormalizedValue, $context);
298        }
299    }
300}