Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.30% covered (success)
91.30%
105 / 115
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureRejectionPolicy
91.30% covered (success)
91.30%
105 / 115
87.50% covered (warning)
87.50%
7 / 8
21.29
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
 keys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
1
 buildSpec
77.78% covered (warning)
77.78%
35 / 45
0.00% covered (danger)
0.00%
0 / 1
12.33
 combinedConfigurationValidator
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 visibilityNormalizer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 commentModeDelegationValidator
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 audienceDelegationValidator
100.00% covered (success)
100.00%
9 / 9
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\Provider\SignatureRejection;
10
11use OCA\Libresign\Enum\SignatureRejectionBehavior;
12use OCA\Libresign\Enum\SignatureRejectionCommentMode;
13use OCA\Libresign\Enum\SignatureRejectionVisibility;
14use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
15use OCA\Libresign\Service\Policy\Contract\IPolicyDefinitionProvider;
16use OCA\Libresign\Service\Policy\Model\PolicyContext;
17use OCA\Libresign\Service\Policy\Model\PolicyLayer;
18use OCA\Libresign\Service\Policy\Model\PolicySpec;
19use OCA\Libresign\Service\Policy\Provider\Helper\DelegationLayerHelper;
20use OCA\Libresign\Service\Policy\Provider\Helper\PolicyKeyNormalizer;
21
22/**
23 * The rules that govern how a signer may refuse to sign.
24 *
25 * Every setting is its own scalar policy key, so an administrator can enforce
26 * one of them and still let the requester choose the others: each key carries
27 * its own allowed values and its own default, and the requester picks inside
28 * what the layers above allow. The keys are grouped under `rejection_enabled`,
29 * which is the setting the others depend on, the way the expiration rules are
30 * grouped under the maximum validity.
31 *
32 * Rules that span more than one key cannot live in a single key's validator, so
33 * the framework hands the whole family to {@see SignatureRejectionPolicyValidator}
34 * before any of it is written: the compound write path passes the final combined
35 * configuration to the parent key, and a signature request is validated the same
36 * way. The outcome therefore never depends on the order in which the keys are
37 * saved, and no part of the framework needs to know how the settings relate to
38 * each other.
39 */
40final class SignatureRejectionPolicy implements IPolicyDefinitionProvider {
41    public const KEY_ENABLED = 'rejection_enabled';
42    public const KEY_BEHAVIOR = 'rejection_behavior';
43    public const KEY_COMMENT_MODE = 'rejection_comment_mode';
44    public const KEY_VISIBILITY = 'rejection_visibility';
45    public const KEY_COMMENT_VISIBILITY = 'rejection_comment_visibility';
46
47    /** The settings that only apply while rejection is enabled. */
48    public const DEPENDENT_KEYS = [
49        self::KEY_BEHAVIOR,
50        self::KEY_COMMENT_MODE,
51        self::KEY_VISIBILITY,
52        self::KEY_COMMENT_VISIBILITY,
53    ];
54
55    public const ALL_KEYS = [
56        self::KEY_ENABLED,
57        self::KEY_BEHAVIOR,
58        self::KEY_COMMENT_MODE,
59        self::KEY_VISIBILITY,
60        self::KEY_COMMENT_VISIBILITY,
61    ];
62
63    public function __construct(
64        private SignatureRejectionPolicyValidator $rejectionPolicyValidator,
65    ) {
66    }
67
68    #[\Override]
69    public function keys(): array {
70        return self::ALL_KEYS;
71    }
72
73    #[\Override]
74    public function get(string|\BackedEnum $policyKey): IPolicyDefinition {
75        $normalizedKey = PolicyKeyNormalizer::normalize($policyKey);
76
77        return match ($normalizedKey) {
78            self::KEY_ENABLED => $this->buildSpec(
79                key: self::KEY_ENABLED,
80                defaultValue: SignatureRejectionPolicyConfig::DEFAULT_ENABLED,
81                allowedValues: [false, true],
82                normalizer: static fn (mixed $rawValue): bool => SignatureRejectionPolicyConfig::normalizeEnabled($rawValue),
83                compositeChildren: self::DEPENDENT_KEYS,
84                compositeValidator: $this->combinedConfigurationValidator(),
85            ),
86            self::KEY_BEHAVIOR => $this->buildSpec(
87                key: self::KEY_BEHAVIOR,
88                defaultValue: SignatureRejectionPolicyConfig::DEFAULT_BEHAVIOR->value,
89                allowedValues: array_column(SignatureRejectionBehavior::cases(), 'value'),
90                normalizer: static fn (mixed $rawValue): string => SignatureRejectionPolicyConfig::normalizeBehavior($rawValue)->value,
91                parentPolicyKey: self::KEY_ENABLED,
92            ),
93            self::KEY_COMMENT_MODE => $this->buildSpec(
94                key: self::KEY_COMMENT_MODE,
95                defaultValue: SignatureRejectionPolicyConfig::DEFAULT_COMMENT_MODE->value,
96                allowedValues: array_column(SignatureRejectionCommentMode::cases(), 'value'),
97                normalizer: static fn (mixed $rawValue): string => SignatureRejectionPolicyConfig::normalizeCommentMode($rawValue)->value,
98                parentPolicyKey: self::KEY_ENABLED,
99                delegatedValueValidator: $this->commentModeDelegationValidator(),
100            ),
101            self::KEY_VISIBILITY => $this->buildSpec(
102                key: self::KEY_VISIBILITY,
103                defaultValue: SignatureRejectionPolicyConfig::DEFAULT_VISIBILITY->value,
104                allowedValues: array_column(SignatureRejectionVisibility::cases(), 'value'),
105                normalizer: $this->visibilityNormalizer(SignatureRejectionPolicyConfig::DEFAULT_VISIBILITY),
106                parentPolicyKey: self::KEY_ENABLED,
107                delegatedValueValidator: $this->audienceDelegationValidator(SignatureRejectionPolicyConfig::DEFAULT_VISIBILITY),
108            ),
109            self::KEY_COMMENT_VISIBILITY => $this->buildSpec(
110                key: self::KEY_COMMENT_VISIBILITY,
111                defaultValue: SignatureRejectionPolicyConfig::DEFAULT_COMMENT_VISIBILITY->value,
112                allowedValues: array_column(SignatureRejectionVisibility::cases(), 'value'),
113                normalizer: $this->visibilityNormalizer(SignatureRejectionPolicyConfig::DEFAULT_COMMENT_VISIBILITY),
114                parentPolicyKey: self::KEY_ENABLED,
115                delegatedValueValidator: $this->audienceDelegationValidator(SignatureRejectionPolicyConfig::DEFAULT_COMMENT_VISIBILITY),
116            ),
117            default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey),
118        };
119    }
120
121    /**
122     * @param list<mixed> $allowedValues
123     * @param list<string> $compositeChildren
124     */
125    private function buildSpec(
126        string $key,
127        mixed $defaultValue,
128        array $allowedValues,
129        \Closure $normalizer,
130        array $compositeChildren = [],
131        ?string $parentPolicyKey = null,
132        ?\Closure $delegatedValueValidator = null,
133        ?\Closure $compositeValidator = null,
134    ): PolicySpec {
135        return new PolicySpec(
136            key: $key,
137            defaultSystemValue: $defaultValue,
138            allowedValues: $allowedValues,
139            normalizer: $normalizer,
140            appConfigKey: $key,
141            resolutionMode: PolicySpec::RESOLUTION_MODE_VALUE_CHOICE,
142            retainDefaultInGroupChoices: true,
143            supportsRequestOverride: true,
144            requestLifecycle: PolicySpec::LIFECYCLE_REQUEST_SNAPSHOT,
145            // The user scope is what lets a requester decide, per signature request,
146            // how rejection behaves on their document. It is never read from the
147            // signer: the effective value always comes from the snapshot frozen on
148            // the document in the context of whoever requested the signature.
149            supportedScopes: [
150                PolicySpec::SCOPE_SYSTEM,
151                PolicySpec::SCOPE_GROUP,
152                PolicySpec::SCOPE_USER,
153            ],
154            groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool {
155                $actorRole = $context->getActorRole();
156
157                if ($actorRole->canManageSystemPolicies) {
158                    return true;
159                }
160
161                if (!$actorRole->canManageGroupPolicies) {
162                    return false;
163                }
164
165                if ($actorRole->manageableGroupCount < 1) {
166                    return false;
167                }
168
169                return DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)
170                    || DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers);
171            },
172            systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool {
173                $actorRole = $context->getActorRole();
174
175                if ($actorRole->canManageSystemPolicies) {
176                    return true;
177                }
178
179                if (!$actorRole->canManageGroupPolicies) {
180                    return false;
181                }
182
183                if (!$existingPolicy->isVisibleToChild() || !$existingPolicy->isAllowChildOverride() || $existingPolicy->getValue() === null) {
184                    return false;
185                }
186
187                if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) {
188                    return true;
189                }
190
191                return $existingPolicy->isCreatedBySystemAdmin();
192            },
193            supportsGroupAdminDelegation: true,
194            delegatedValueValidator: $delegatedValueValidator,
195            // Dependent settings are edited as part of the rejection policy, never
196            // as a rule of their own, so the workbench shows them under the setting
197            // they belong to.
198            helper: $parentPolicyKey !== null,
199            parentPolicyKey: $parentPolicyKey,
200            compositeChildren: $compositeChildren,
201            compositeValidator: $compositeValidator,
202        );
203    }
204
205    /**
206     * The five settings are only meaningful together, so what is validated is
207     * the configuration the write produces, never a single key.
208     */
209    private function combinedConfigurationValidator(): \Closure {
210        return function (array $normalizedValues, array $submittedKeys): void {
211            $this->rejectionPolicyValidator->validateLayer($normalizedValues, $submittedKeys);
212        };
213    }
214
215    private function visibilityNormalizer(SignatureRejectionVisibility $fallback): \Closure {
216        return static fn (mixed $rawValue): string
217            => SignatureRejectionPolicyConfig::normalizeVisibility($rawValue, $fallback)->value;
218    }
219
220    /**
221     * A delegated rule may tighten what the layer above defined, never loosen it:
222     * a comment the parent requires stays required.
223     */
224    private function commentModeDelegationValidator(): \Closure {
225        return static function (mixed $proposedNormalizedValue, mixed $parentSeedNormalizedValue): void {
226            $parent = SignatureRejectionPolicyConfig::normalizeCommentMode($parentSeedNormalizedValue);
227            if ($parent !== SignatureRejectionCommentMode::REQUIRED) {
228                return;
229            }
230
231            $proposed = SignatureRejectionPolicyConfig::normalizeCommentMode($proposedNormalizedValue);
232            if ($proposed === SignatureRejectionCommentMode::REQUIRED) {
233                return;
234            }
235
236            throw new \InvalidArgumentException(
237                'A delegated rule cannot make the rejection comment optional when it is required by a parent policy',
238            );
239        };
240    }
241
242    /**
243     * The same principle applied to an audience: a delegated rule may narrow the
244     * audience defined above, never widen it.
245     */
246    private function audienceDelegationValidator(SignatureRejectionVisibility $fallback): \Closure {
247        return static function (mixed $proposedNormalizedValue, mixed $parentSeedNormalizedValue) use ($fallback): void {
248            $parent = SignatureRejectionPolicyConfig::normalizeVisibility($parentSeedNormalizedValue, $fallback);
249            $proposed = SignatureRejectionPolicyConfig::normalizeVisibility($proposedNormalizedValue, $fallback);
250            if ($parent->covers($proposed)) {
251                return;
252            }
253
254            throw new \InvalidArgumentException(
255                'A delegated rule cannot disclose a rejection to a wider audience than the parent policy allows',
256            );
257        };
258    }
259}