Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.46% covered (success)
97.46%
115 / 118
81.25% covered (warning)
81.25%
13 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureRejectionFilePolicyApplier
97.46% covered (success)
97.46%
115 / 118
81.25% covered (warning)
81.25%
13 / 16
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 apply
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 sync
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 supportsCoreFlowSync
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storeConfiguration
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 assertSubmittedValuesWereApplied
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 validateCombination
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolverForUserId
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 readSubmittedValues
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 readRequesterChoices
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 readStoredValues
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 readSnapshotEntries
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 assertFrozenConfigurationIsKept
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 frozenConfiguration
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 translate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translateWithParameters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\FilePolicy;
10
11use OCA\Libresign\Db\File as FileEntity;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Exception\LibresignException;
14use OCA\Libresign\Service\FileService;
15use OCA\Libresign\Service\Policy\AbstractFilePolicyApplier;
16use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
17use OCA\Libresign\Service\Policy\PolicyService;
18use OCA\Libresign\Service\Policy\Provider\SignatureRejection\SignatureRejectionPolicy;
19use OCA\Libresign\Service\Policy\Provider\SignatureRejection\SignatureRejectionPolicyConfig;
20use OCA\Libresign\Service\Policy\Provider\SignatureRejection\SignatureRejectionPolicyValidator;
21use OCA\Libresign\Service\SignatureRejection\SignatureRejectionPolicyService;
22use OCP\AppFramework\Http;
23use OCP\IL10N;
24use OCP\IUser;
25
26/**
27 * Owns the lifecycle of the rejection rules of a document.
28 *
29 * The five rejection settings are resolved for whoever requests the signature,
30 * with the choices sent along the request applied on top of what the layers
31 * above allow. What comes out is frozen on the document, one snapshot entry per
32 * setting, and is the effective configuration for the whole signing flow.
33 *
34 * While the request is still a draft that was never sent the requester may
35 * change their choices, and the draft is revalidated against the current
36 * administrative policy on every update: a choice the administrator no longer
37 * allows is dropped in favor of what is now inherited, so a workflow never
38 * starts under a rule that has been revoked. Once the flow starts the stored
39 * configuration is frozen, even if the request later returns to draft: it can
40 * no longer change, but a client resending the values the request already has
41 * stays an idempotent update rather than an error.
42 */
43class SignatureRejectionFilePolicyApplier extends AbstractFilePolicyApplier {
44    private readonly ?SignatureRejectionPolicyService $storedValueReader;
45    private readonly SignatureRejectionPolicyValidator $rejectionPolicyValidator;
46
47    public function __construct(
48        PolicyService $policyService,
49        FileService $fileService,
50        ?IL10N $l10n = null,
51        ?FileMapper $fileMapper = null,
52    ) {
53        parent::__construct($policyService, $fileService, $l10n, $fileMapper);
54        $this->storedValueReader = $fileMapper === null
55            ? null
56            : new SignatureRejectionPolicyService($fileMapper);
57        $this->rejectionPolicyValidator = new SignatureRejectionPolicyValidator($l10n);
58    }
59
60    #[\Override]
61    public function apply(FileEntity $file, array $data): void {
62        $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null;
63        $submittedValues = $this->readSubmittedValues($data);
64
65        $this->storeConfiguration(
66            $file,
67            $data,
68            fn (string $policyKey, array $overrides, ?array $activeContext): ResolvedPolicy
69                => $activeContext === null
70                    ? $this->policyService->resolveForUser($policyKey, $user, $overrides)
71                    : $this->policyService->resolveForUser($policyKey, $user, $overrides, $activeContext),
72            $submittedValues,
73        );
74    }
75
76    #[\Override]
77    public function sync(FileEntity $file, array $data): void {
78        $submittedValues = $this->readSubmittedValues($data);
79
80        if ($this->isPolicySnapshotFrozen($file, ...SignatureRejectionPolicy::ALL_KEYS)) {
81            $this->assertFrozenConfigurationIsKept($file, $submittedValues);
82
83            // The configuration is frozen: an identical resend or a value-less
84            // update changes nothing, and only a request that never recorded a
85            // configuration still gets the disabled default written for it.
86            if ($file->isEnvelope() || $this->readStoredValues($file) !== []) {
87                return;
88            }
89        } elseif ($file->isEnvelope() && $submittedValues === []) {
90            // An envelope is created before the file policy appliers run, so the
91            // configuration the request was created with lives on the documents it
92            // contains, and updating an envelope never re-synchronizes them.
93            // Writing a freshly resolved configuration here would therefore shadow
94            // the stored choice, so an envelope is only ever written when the
95            // requester explicitly sends new values.
96            return;
97        }
98
99        $metadataBeforeUpdate = $file->getMetadata() ?? [];
100        $this->storeConfiguration(
101            $file,
102            $data,
103            // A draft keeps the choices the requester already made, but they are
104            // resolved again: one the administrator no longer allows falls back to
105            // what the document now inherits.
106            $this->resolverForUserId($file, $this->readRequesterChoices($file)),
107            $submittedValues,
108        );
109
110        if (($file->getMetadata() ?? []) !== $metadataBeforeUpdate) {
111            $this->fileService->update($file);
112        }
113    }
114
115    #[\Override]
116    public function supportsCoreFlowSync(): bool {
117        return true;
118    }
119
120    /**
121     * Resolve the five settings with the given choices applied, refuse anything
122     * the layers above do not allow, and freeze the result on the document.
123     *
124     * @param callable(string, array<string, mixed>, ?array<string, mixed>): ResolvedPolicy $resolve
125     * @param array<string, mixed> $submittedValues Choices sent with this very request
126     */
127    private function storeConfiguration(
128        FileEntity $file,
129        array $data,
130        callable $resolve,
131        array $submittedValues,
132    ): void {
133        $activeContext = $this->extractActiveContext($data);
134
135        /** @var array<string, ResolvedPolicy> $resolvedPolicies */
136        $resolvedPolicies = [];
137        $resolvedValues = [];
138        foreach (SignatureRejectionPolicy::ALL_KEYS as $policyKey) {
139            $resolvedPolicy = $resolve($policyKey, $submittedValues, $activeContext);
140            $resolvedPolicies[$policyKey] = $resolvedPolicy;
141            $resolvedValues[$policyKey] = $resolvedPolicy->getEffectiveValue();
142        }
143
144        $this->assertSubmittedValuesWereApplied($submittedValues, $resolvedPolicies);
145        $configuration = $this->validateCombination($resolvedValues, array_keys($submittedValues));
146        $effectiveValues = $configuration->toKeyedValues();
147
148        foreach ($resolvedPolicies as $policyKey => $resolvedPolicy) {
149            $this->storePolicySnapshot($file, $resolvedPolicy, $effectiveValues[$policyKey]);
150        }
151    }
152
153    /**
154     * A setting the requester asked for is only accepted when it survives the
155     * resolution: anything the administrative layers keep for themselves comes
156     * back as the inherited value instead, and asking for it is an error rather
157     * than a silently different document.
158     *
159     * @param array<string, mixed> $submittedValues
160     * @param array<string, ResolvedPolicy> $resolvedPolicies
161     */
162    private function assertSubmittedValuesWereApplied(array $submittedValues, array $resolvedPolicies): void {
163        foreach ($submittedValues as $policyKey => $submittedValue) {
164            $resolvedPolicy = $resolvedPolicies[$policyKey] ?? null;
165            if (!$resolvedPolicy instanceof ResolvedPolicy) {
166                continue;
167            }
168
169            $requested = SignatureRejectionPolicyConfig::normalizeKeyedValue($policyKey, $submittedValue);
170            if ($requested === SignatureRejectionPolicyConfig::normalizeKeyedValue($policyKey, $resolvedPolicy->getEffectiveValue())) {
171                continue;
172            }
173
174            if ($policyKey === SignatureRejectionPolicy::KEY_ENABLED && $requested === true) {
175                throw new LibresignException(
176                    // TRANSLATORS Error shown when a signature request tries to offer signature rejection while a higher-level LibreSign policy keeps it disabled.
177                    $this->translate('Signature rejection is disabled by policy and cannot be enabled for this document.'),
178                    Http::STATUS_UNPROCESSABLE_ENTITY,
179                );
180            }
181
182            throw new LibresignException(
183                $this->translateWithParameters(
184                    // TRANSLATORS Error shown when a signature request asks for a rejection setting that a higher-level LibreSign policy does not allow. The first placeholder receives the setting name, the second the scope that blocked it.
185                    'The rejection setting %1$s cannot be used on this document: it is defined by %2$s.',
186                    [$policyKey, $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope()],
187                ),
188                Http::STATUS_UNPROCESSABLE_ENTITY,
189            );
190        }
191    }
192
193    /**
194     * @param array<string, mixed> $resolvedValues
195     * @param list<string> $submittedKeys
196     */
197    private function validateCombination(array $resolvedValues, array $submittedKeys): SignatureRejectionPolicyConfig {
198        try {
199            return $this->rejectionPolicyValidator->validateRequest($resolvedValues, $submittedKeys);
200        } catch (\InvalidArgumentException $exception) {
201            throw new LibresignException($exception->getMessage(), Http::STATUS_UNPROCESSABLE_ENTITY);
202        }
203    }
204
205    /**
206     * Updating an existing request resolves from the stored owner, exactly like
207     * every other file policy applier. A choice the document already stores and
208     * that this request does not send again is replayed, not treated as a new
209     * choice of whoever updates the request now.
210     *
211     * @param array<string, mixed> $requesterChoices
212     * @return callable(string, array<string, mixed>, ?array<string, mixed>): ResolvedPolicy
213     */
214    private function resolverForUserId(FileEntity $file, array $requesterChoices): callable {
215        return function (string $policyKey, array $overrides, ?array $activeContext) use ($file, $requesterChoices): ResolvedPolicy {
216            if (!array_key_exists($policyKey, $overrides) && array_key_exists($policyKey, $requesterChoices)) {
217                $storedChoice = [$policyKey => $requesterChoices[$policyKey]];
218                return $activeContext === null
219                    ? $this->policyService->resolveForUserIdWithStoredRequestOverrides($policyKey, $file->getUserId(), $storedChoice)
220                    : $this->policyService->resolveForUserIdWithStoredRequestOverrides($policyKey, $file->getUserId(), $storedChoice, $activeContext);
221            }
222
223            return $activeContext === null
224                ? $this->policyService->resolveForUserId($policyKey, $file->getUserId(), $overrides)
225                : $this->policyService->resolveForUserId($policyKey, $file->getUserId(), $overrides, $activeContext);
226        };
227    }
228
229    /**
230     * The rejection settings sent with this request, in the order the policy
231     * defines them.
232     *
233     * @return array<string, mixed>
234     */
235    private function readSubmittedValues(array $data): array {
236        if (!isset($data['policyOverrides']) || !is_array($data['policyOverrides'])) {
237            return [];
238        }
239
240        $submittedValues = [];
241        foreach (SignatureRejectionPolicy::ALL_KEYS as $policyKey) {
242            if (array_key_exists($policyKey, $data['policyOverrides'])) {
243                $submittedValues[$policyKey] = $data['policyOverrides'][$policyKey];
244            }
245        }
246
247        return $submittedValues;
248    }
249
250    /**
251     * The settings the requester chose themselves, as opposed to the ones the
252     * document inherited.
253     *
254     * @return array<string, mixed>
255     */
256    private function readRequesterChoices(FileEntity $file): array {
257        $requesterChoices = [];
258        foreach ($this->readSnapshotEntries($file) as $policyKey => $entry) {
259            if (($entry['sourceScope'] ?? null) === 'request') {
260                $requesterChoices[$policyKey] = $entry['effectiveValue'];
261            }
262        }
263
264        return $requesterChoices;
265    }
266
267    /**
268     * @return array<string, mixed>
269     */
270    private function readStoredValues(FileEntity $file): array {
271        $storedValues = [];
272        foreach ($this->readSnapshotEntries($file) as $policyKey => $entry) {
273            $storedValues[$policyKey] = $entry['effectiveValue'];
274        }
275
276        return $storedValues;
277    }
278
279    /**
280     * @return array<string, array<string, mixed>>
281     */
282    private function readSnapshotEntries(FileEntity $file): array {
283        $metadata = $file->getMetadata() ?? [];
284        $policySnapshot = $metadata['policy_snapshot'] ?? null;
285        if (!is_array($policySnapshot)) {
286            return [];
287        }
288
289        $entries = [];
290        foreach (SignatureRejectionPolicy::ALL_KEYS as $policyKey) {
291            $entry = $policySnapshot[$policyKey] ?? null;
292            if (is_array($entry) && array_key_exists('effectiveValue', $entry)) {
293                $entries[$policyKey] = $entry;
294            }
295        }
296
297        return $entries;
298    }
299
300    /**
301     * Once the flow starts the configuration is frozen: it cannot change, but a
302     * client resending the complete form state with the values the request
303     * already has stays an idempotent update. The comparison uses the effective
304     * stored configuration, which for an envelope lives on the documents it
305     * contains.
306     *
307     * @param array<string, mixed> $submittedValues
308     */
309    private function assertFrozenConfigurationIsKept(FileEntity $file, array $submittedValues): void {
310        if ($submittedValues === []) {
311            return;
312        }
313
314        $frozenValues = $this->frozenConfiguration($file)->toKeyedValues();
315        foreach ($submittedValues as $policyKey => $submittedValue) {
316            if (SignatureRejectionPolicyConfig::normalizeKeyedValue($policyKey, $submittedValue) === $frozenValues[$policyKey]) {
317                continue;
318            }
319
320            throw new LibresignException(
321                // TRANSLATORS Error shown when someone tries to change how signers may reject a document after the signing flow already started.
322                $this->translate('The signature rejection settings cannot be changed after the signing flow has started.'),
323                Http::STATUS_UNPROCESSABLE_ENTITY,
324            );
325        }
326    }
327
328    /**
329     * The effective stored configuration of the request, read the same way the
330     * signing flow reads it, so an envelope answers with the configuration
331     * stored on the documents it contains.
332     */
333    private function frozenConfiguration(FileEntity $file): SignatureRejectionPolicyConfig {
334        if ($this->storedValueReader !== null) {
335            return $this->storedValueReader->getConfig($file);
336        }
337
338        return SignatureRejectionPolicyConfig::fromKeyedValues($this->readStoredValues($file));
339    }
340
341    private function translate(string $message): string {
342        return $this->l10n?->t($message) ?? $message;
343    }
344
345    /** @param list<mixed> $parameters */
346    private function translateWithParameters(string $message, array $parameters): string {
347        return $this->l10n?->t($message, $parameters) ?? vsprintf($message, $parameters);
348    }
349}