Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
85.23% |
75 / 88 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
| SignerRemovalService | |
85.23% |
75 / 88 |
|
60.00% |
6 / 10 |
43.90 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| remove | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| persistRemoval | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
11 | |||
| getRemovalTargets | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
6.01 | |||
| requireSigner | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| assertCanRemove | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| rejectRemoval | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| revertToDraftWhenEmpty | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| invalidateCaches | |
52.38% |
11 / 21 |
|
0.00% |
0 / 1 |
9.89 | |||
| notifyCancellation | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Service; |
| 10 | |
| 11 | use OCA\Libresign\Db\File as FileEntity; |
| 12 | use OCA\Libresign\Db\FileElementMapper; |
| 13 | use OCA\Libresign\Db\FileMapper; |
| 14 | use OCA\Libresign\Db\NotificationDeliveryMapper; |
| 15 | use OCA\Libresign\Db\SignRequest as SignRequestEntity; |
| 16 | use OCA\Libresign\Db\SignRequestMapper; |
| 17 | use OCA\Libresign\Enum\FileStatus; |
| 18 | use OCA\Libresign\Enum\SignRequestStatus; |
| 19 | use OCA\Libresign\Events\SignRequestCanceledEvent; |
| 20 | use OCA\Libresign\Exception\LibresignException; |
| 21 | use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; |
| 22 | use OCA\Libresign\Service\SignerRemoval\RemovalChanges; |
| 23 | use OCP\AppFramework\Db\DoesNotExistException; |
| 24 | use OCP\AppFramework\Db\TTransactional; |
| 25 | use OCP\AppFramework\Http; |
| 26 | use OCP\EventDispatcher\IEventDispatcher; |
| 27 | use OCP\IDBConnection; |
| 28 | use OCP\IL10N; |
| 29 | use Psr\Log\LoggerInterface; |
| 30 | |
| 31 | /** |
| 32 | * Coordinates an atomic signer removal and publishes notifications after commit. |
| 33 | */ |
| 34 | class SignerRemovalService { |
| 35 | use TTransactional; |
| 36 | |
| 37 | public function __construct( |
| 38 | private FileMapper $fileMapper, |
| 39 | private SignRequestMapper $signRequestMapper, |
| 40 | private IdentifyMethodService $identifyMethodService, |
| 41 | private FileElementMapper $fileElementMapper, |
| 42 | private SequentialSigningService $sequentialSigningService, |
| 43 | private FileStatusService $fileStatusService, |
| 44 | private IEventDispatcher $eventDispatcher, |
| 45 | private IL10N $l10n, |
| 46 | private IDBConnection $db, |
| 47 | private LoggerInterface $logger, |
| 48 | private NotificationDeliveryService $notificationDelivery, |
| 49 | private NotificationDeliveryMapper $notificationDeliveries, |
| 50 | ) { |
| 51 | } |
| 52 | |
| 53 | public function remove(int $fileId, int $signRequestId, bool $notify = true): void { |
| 54 | $changes = new RemovalChanges(); |
| 55 | try { |
| 56 | $this->atomic( |
| 57 | fn () => $this->persistRemoval($fileId, $signRequestId, $changes, $notify), |
| 58 | $this->db, |
| 59 | ); |
| 60 | } finally { |
| 61 | // The database transaction does not roll back application caches. |
| 62 | $this->invalidateCaches($changes); |
| 63 | } |
| 64 | |
| 65 | // The cancellation outbox is already committed. Delivery is never |
| 66 | // attempted while the removed signer still holds a database lock. |
| 67 | $this->notificationDelivery->flushCommitted(); |
| 68 | $this->sequentialSigningService->notifyActivatedSigners($changes->activatedSigners); |
| 69 | } |
| 70 | |
| 71 | private function persistRemoval(int $fileId, int $signRequestId, RemovalChanges $changes, bool $notify): void { |
| 72 | $targets = $this->getRemovalTargets($fileId, $signRequestId); |
| 73 | [$changes->file, $changes->signRequest] = $targets[0]; |
| 74 | $changes->identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequestId); |
| 75 | |
| 76 | // Validate the full envelope before deleting anything. |
| 77 | foreach ($targets as [$file, $signRequest, $siblings]) { |
| 78 | $this->assertCanRemove($file, $signRequest, $siblings); |
| 79 | $changes->track($file, $siblings); |
| 80 | if ($file->hasParent()) { |
| 81 | $parent = $this->fileMapper->getById($file->getParentFileId()); |
| 82 | $changes->files[$parent->getId()] = $parent; |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // Persist cancellation intents in the same transaction as signer removal. |
| 87 | // The sender uses an encrypted snapshot after the signer's rows are gone. |
| 88 | if ($notify && $changes->file !== null && $changes->signRequest !== null |
| 89 | && $changes->signRequest->getStatus() === SignRequestStatus::ABLE_TO_SIGN->value) { |
| 90 | $this->notifyCancellation($changes->file, $changes->signRequest, $changes->identifyMethods); |
| 91 | } |
| 92 | |
| 93 | foreach ($targets as [$file, $signRequest]) { |
| 94 | // Cancel unsent invitations before deleting the signer's rows. |
| 95 | // The cancellation notice is a separate event and remains queued. |
| 96 | $this->notificationDeliveries->cancelPendingInvitations($signRequest->getId()); |
| 97 | if (!$this->signRequestMapper->deleteIfUnsigned($signRequest)) { |
| 98 | $this->rejectRemoval(); |
| 99 | } |
| 100 | $this->identifyMethodService->deleteBySignRequestId($signRequest->getId()); |
| 101 | foreach ($this->fileElementMapper->getByFileIdAndSignRequestId($file->getId(), $signRequest->getId()) as $element) { |
| 102 | $this->fileElementMapper->delete($element); |
| 103 | } |
| 104 | foreach ($this->sequentialSigningService->activateAfterDeletion($file, $signRequest->getSigningOrder()) as $activated) { |
| 105 | $changes->activated($activated); |
| 106 | } |
| 107 | $this->revertToDraftWhenEmpty($file); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Lock the entire set of signing requests for each affected file, not only |
| 113 | * the removed signer. A concurrent signature must not invalidate preflight. |
| 114 | * |
| 115 | * @return list<array{FileEntity, SignRequestEntity, list<SignRequestEntity>}> |
| 116 | */ |
| 117 | private function getRemovalTargets(int $fileId, int $signRequestId): array { |
| 118 | $rootSigners = $this->signRequestMapper->getByFileIdForUpdate($fileId); |
| 119 | $file = $this->fileMapper->getByIdForUpdate($fileId); |
| 120 | $targets = [[$file, $this->requireSigner($rootSigners, $signRequestId), $rootSigners]]; |
| 121 | if (!$file->isEnvelope()) { |
| 122 | return $targets; |
| 123 | } |
| 124 | |
| 125 | // Match all identifying factors to avoid removing another participant |
| 126 | // who only shares an email address or other single factor. |
| 127 | // De-duplicate before acquiring locks for child files. |
| 128 | $childIds = []; |
| 129 | foreach ($this->signRequestMapper->getEnvelopeChildrenForRemoval($fileId, $signRequestId) as $childSigner) { |
| 130 | $childIds[$childSigner->getFileId()][$childSigner->getId()] = true; |
| 131 | } |
| 132 | ksort($childIds); |
| 133 | |
| 134 | foreach ($childIds as $childFileId => $requestIds) { |
| 135 | $signers = $this->signRequestMapper->getByFileIdForUpdate($childFileId); |
| 136 | $child = $this->fileMapper->getByIdForUpdate($childFileId); |
| 137 | if ($child->getParentFileId() !== $fileId) { |
| 138 | throw new DoesNotExistException('Envelope child is no longer associated with its parent'); |
| 139 | } |
| 140 | foreach (array_keys($requestIds) as $requestId) { |
| 141 | $targets[] = [$child, $this->requireSigner($signers, $requestId), $signers]; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | return $targets; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * @param list<SignRequestEntity> $signers |
| 150 | */ |
| 151 | private function requireSigner(array $signers, int $signRequestId): SignRequestEntity { |
| 152 | foreach ($signers as $signer) { |
| 153 | if ($signer->getId() === $signRequestId) { |
| 154 | return $signer; |
| 155 | } |
| 156 | } |
| 157 | throw new DoesNotExistException('Sign request is not associated with the file'); |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * @param list<SignRequestEntity> $siblings |
| 162 | */ |
| 163 | private function assertCanRemove(FileEntity $file, SignRequestEntity $signRequest, array $siblings): void { |
| 164 | if (!(FileStatus::tryFrom($file->getStatus())?->allowsSignerRemoval() ?? false) |
| 165 | || $signRequest->hasCompletedSigning() |
| 166 | ) { |
| 167 | $this->rejectRemoval(); |
| 168 | } |
| 169 | |
| 170 | // The file status may not yet reflect another signer's completed update. |
| 171 | foreach ($siblings as $sibling) { |
| 172 | if ($sibling->hasCompletedSigning()) { |
| 173 | $this->rejectRemoval(); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | private function rejectRemoval(): never { |
| 179 | // TRANSLATORS Error shown when removing a participant would discard a completed signature. |
| 180 | throw new LibresignException($this->l10n->t('A completed signer cannot be removed.'), Http::STATUS_UNPROCESSABLE_ENTITY); |
| 181 | } |
| 182 | |
| 183 | private function revertToDraftWhenEmpty(FileEntity $file): void { |
| 184 | if ($this->signRequestMapper->getByFileId($file->getId()) !== []) { |
| 185 | return; |
| 186 | } |
| 187 | $this->fileStatusService->markPolicySnapshotFrozen($file, FileStatus::DRAFT->value); |
| 188 | $file->setStatus(FileStatus::DRAFT->value); |
| 189 | $this->fileStatusService->update($file); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Cache failure must not turn an already committed removal into an API error, |
| 194 | * nor hide the original error if persistence rolled back. |
| 195 | */ |
| 196 | private function invalidateCaches(RemovalChanges $changes): void { |
| 197 | foreach ($changes->signRequests as $signRequest) { |
| 198 | try { |
| 199 | $this->signRequestMapper->invalidateCachedEntity($signRequest); |
| 200 | } catch (\Exception $e) { |
| 201 | $this->logger->warning('Unable to invalidate signer cache after removal', [ |
| 202 | 'exception' => $e, |
| 203 | 'signRequestId' => $signRequest->getId(), |
| 204 | ]); |
| 205 | } |
| 206 | } |
| 207 | foreach ($changes->files as $file) { |
| 208 | try { |
| 209 | $this->fileMapper->invalidateCachedEntity($file); |
| 210 | } catch (\Exception $e) { |
| 211 | $this->logger->warning('Unable to invalidate file cache after signer removal', [ |
| 212 | 'exception' => $e, |
| 213 | 'fileId' => $file->getId(), |
| 214 | ]); |
| 215 | } |
| 216 | try { |
| 217 | // Invalidate instead of publishing a potentially stale worker status. |
| 218 | $this->fileStatusService->invalidateCachedStatus($file); |
| 219 | } catch (\Exception $e) { |
| 220 | $this->logger->warning('Unable to invalidate file status cache after signer removal', [ |
| 221 | 'exception' => $e, |
| 222 | 'fileId' => $file->getId(), |
| 223 | ]); |
| 224 | } |
| 225 | } |
| 226 | $this->identifyMethodService->clearCache(); |
| 227 | } |
| 228 | |
| 229 | /** @param array<string, array<IIdentifyMethod>> $identifyMethods */ |
| 230 | private function notifyCancellation(FileEntity $file, SignRequestEntity $signRequest, array $identifyMethods): void { |
| 231 | foreach ($identifyMethods as $group) { |
| 232 | foreach ($group as $identifyMethod) { |
| 233 | // A registration failure must abort deletion: silently dropping |
| 234 | // the intent here would violate the transactional outbox contract. |
| 235 | $this->eventDispatcher->dispatchTyped(new SignRequestCanceledEvent($signRequest, $file, $identifyMethod)); |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | } |