Skip to content

Commit 1859809

Browse files
feat(archiving): clean up archive overrides when access is lost
Follow the pattern established by server group shares and the circles app: shares stay group/circle-targeted with dynamic access resolution, and only the materialized per-user state is removed once membership or sharing ends. The new ArchiveCleanupService drops a user's archive override after verifying they no longer have access to the node, and is triggered when - a user is removed from a group (UserRemovedEvent), - a member is removed from a circle (CircleMemberRemovedEvent, including inherited members of a removed nested circle), - a group is deleted or a circle destroyed (the existing receiver cleanup now also purges stale overrides on the affected nodes), - a share is deleted (single receiver check for user shares, full node purge for group and circle shares), - a user account is deleted (all overrides of that user are removed). Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent faf39c4 commit 1859809

8 files changed

Lines changed: 491 additions & 2 deletions

File tree

lib/AppInfo/Application.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OC\OCM\OCMSignatoryManager;
1212
use OCA\Analytics\Datasource\DatasourceEvent;
1313
use OCA\Circles\Events\CircleDestroyedEvent;
14+
use OCA\Circles\Events\CircleMemberRemovedEvent;
1415
use OCA\Tables\Capabilities;
1516
use OCA\Tables\Config\ConfigLexicon;
1617
use OCA\Tables\Event\RowDeletedEvent;
@@ -20,6 +21,7 @@
2021
use OCA\Tables\Federation\FederationProvider;
2122
use OCA\Tables\Listener\AddMissingIndicesListener;
2223
use OCA\Tables\Listener\AnalyticsDatasourceListener;
24+
use OCA\Tables\Listener\ArchiveCleanupListener;
2325
use OCA\Tables\Listener\LoadAdditionalEntriesListener;
2426
use OCA\Tables\Listener\LoadAdditionalListener;
2527
use OCA\Tables\Listener\ReceiverCleanupListener;
@@ -50,6 +52,7 @@
5052
use OCP\Federation\ICloudFederationProvider;
5153
use OCP\Federation\ICloudFederationProviderManager;
5254
use OCP\Group\Events\GroupDeletedEvent;
55+
use OCP\Group\Events\UserRemovedEvent;
5356
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
5457
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
5558
use OCP\Security\Signature\ISignatoryManager;
@@ -109,6 +112,8 @@ public function register(IRegistrationContext $context): void {
109112
$context->registerEventListener(UserDeletedEvent::class, ReceiverCleanupListener::class);
110113
$context->registerEventListener(GroupDeletedEvent::class, ReceiverCleanupListener::class);
111114
$context->registerEventListener(CircleDestroyedEvent::class, ReceiverCleanupListener::class);
115+
$context->registerEventListener(UserRemovedEvent::class, ArchiveCleanupListener::class);
116+
$context->registerEventListener(CircleMemberRemovedEvent::class, ArchiveCleanupListener::class);
112117
$context->registerEventListener(LocalOCMDiscoveryEvent::class, ResourceTypeRegisterListener::class);
113118

114119
$context->registerSearchProvider(SearchTablesProvider::class);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Tables\Listener;
10+
11+
use OCA\Circles\Events\CircleMemberRemovedEvent;
12+
use OCA\Circles\Model\Member;
13+
use OCA\Tables\Constants\ShareReceiverType;
14+
use OCA\Tables\Service\ArchiveCleanupService;
15+
use OCP\EventDispatcher\Event;
16+
use OCP\EventDispatcher\IEventListener;
17+
use OCP\Group\Events\UserRemovedEvent;
18+
use Psr\Log\LoggerInterface;
19+
20+
/**
21+
* Removes per-user archive overrides when a user loses access to shared
22+
* tables or applications because their group or circle membership ended.
23+
*
24+
* @template-implements IEventListener<Event>
25+
*/
26+
class ArchiveCleanupListener implements IEventListener {
27+
public function __construct(
28+
private readonly ArchiveCleanupService $archiveCleanupService,
29+
private readonly LoggerInterface $logger,
30+
) {
31+
}
32+
33+
public function handle(Event $event): void {
34+
if ($event instanceof UserRemovedEvent) {
35+
$this->archiveCleanupService->cleanupAfterMembershipLoss(
36+
[$event->getUser()->getUID()],
37+
$event->getGroup()->getGID(),
38+
ShareReceiverType::GROUP,
39+
);
40+
return;
41+
}
42+
43+
if ($event instanceof CircleMemberRemovedEvent) {
44+
$this->handleCircleMemberRemoved($event);
45+
}
46+
}
47+
48+
private function handleCircleMemberRemoved(CircleMemberRemovedEvent $event): void {
49+
try {
50+
$member = $event->getMember();
51+
if ($member === null) {
52+
return;
53+
}
54+
55+
if ($member->getUserType() === Member::TYPE_CIRCLE) {
56+
$basedOn = $member->getBasedOn();
57+
$members = $basedOn !== null ? $basedOn->getInheritedMembers() : [];
58+
} else {
59+
$members = [$member];
60+
}
61+
62+
$userIds = [];
63+
foreach ($members as $affectedMember) {
64+
if ($affectedMember->getUserType() === Member::TYPE_USER) {
65+
$userIds[] = $affectedMember->getUserId();
66+
}
67+
}
68+
69+
if ($userIds === []) {
70+
return;
71+
}
72+
73+
$this->archiveCleanupService->cleanupAfterMembershipLoss(
74+
$userIds,
75+
$event->getCircle()->getSingleId(),
76+
ShareReceiverType::CIRCLE,
77+
);
78+
} catch (\Throwable $e) {
79+
$this->logger->warning('cleanup of archive overrides after circle member removal failed: ' . $e->getMessage(), [
80+
'exception' => $e,
81+
]);
82+
}
83+
}
84+
}

lib/Listener/ReceiverCleanupListener.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
namespace OCA\Tables\Listener;
99

1010
use OCA\Circles\Events\CircleDestroyedEvent;
11+
use OCA\Tables\AppInfo\Application;
1112
use OCA\Tables\Constants\ShareReceiverType;
1213
use OCA\Tables\Db\ShareMapper;
14+
use OCA\Tables\Service\ArchiveCleanupService;
1315
use OCP\EventDispatcher\Event;
1416
use OCP\EventDispatcher\IEventListener;
1517
use OCP\Group\Events\GroupDeletedEvent;
@@ -20,17 +22,50 @@
2022
class ReceiverCleanupListener implements IEventListener {
2123
public function __construct(
2224
private readonly ShareMapper $shareMapper,
25+
private readonly ArchiveCleanupService $archiveCleanupService,
2326
private readonly LoggerInterface $logger,
2427
) {
2528
}
2629

2730
public function handle(Event $event): void {
2831
if ($event instanceof UserDeletedEvent) {
2932
$this->cleanupByParticipant(ShareReceiverType::USER, $event->getUser()->getUID());
33+
$this->archiveCleanupService->cleanupDeletedUser($event->getUser()->getUID());
3034
} elseif ($event instanceof GroupDeletedEvent) {
31-
$this->cleanupByParticipant(ShareReceiverType::GROUP, $event->getGroup()->getGID());
35+
$this->cleanupReceiverGone(ShareReceiverType::GROUP, $event->getGroup()->getGID());
3236
} elseif ($event instanceof CircleDestroyedEvent) {
33-
$this->cleanupByParticipant(ShareReceiverType::CIRCLE, $event->getCircle()->getSingleId());
37+
$this->cleanupReceiverGone(ShareReceiverType::CIRCLE, $event->getCircle()->getSingleId());
38+
}
39+
}
40+
41+
/**
42+
* Delete all shares of a removed group or circle receiver and drop the
43+
* archive overrides of every ex-member who lost access to the affected
44+
* nodes with those shares.
45+
*/
46+
private function cleanupReceiverGone(string $type, string $participant): void {
47+
try {
48+
$nodes = $this->shareMapper->findNodesByReceiver($participant, $type);
49+
} catch (\Throwable $e) {
50+
$this->logger->warning('collecting nodes shared with deleted receiver has failed: ' . $e->getMessage(), [
51+
'exception' => $e,
52+
'receiver_type' => $type,
53+
'receiver' => $participant,
54+
]);
55+
$nodes = [];
56+
}
57+
58+
$this->cleanupByParticipant($type, $participant);
59+
60+
foreach ($nodes as $node) {
61+
$nodeType = match ($node['nodeType']) {
62+
'table' => Application::NODE_TYPE_TABLE,
63+
'context' => Application::NODE_TYPE_CONTEXT,
64+
default => null,
65+
};
66+
if ($nodeType !== null) {
67+
$this->archiveCleanupService->purgeNodeOverrides($nodeType, $node['nodeId']);
68+
}
3469
}
3570
}
3671

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Tables\Service;
10+
11+
use OCA\Tables\AppInfo\Application;
12+
use OCA\Tables\Constants\ShareReceiverType;
13+
use OCA\Tables\Db\Share;
14+
use OCA\Tables\Db\ShareMapper;
15+
use OCA\Tables\Db\UserArchiveMapper;
16+
use Psr\Log\LoggerInterface;
17+
18+
/**
19+
* Removes per-user archive overrides once a user loses access to a node.
20+
*
21+
* Overrides are pure UX metadata: a stale row has no effect for a user
22+
* without access (the entity flag fallback applies), so every method here
23+
* logs failures instead of throwing to never break the triggering flow.
24+
*/
25+
class ArchiveCleanupService {
26+
27+
public function __construct(
28+
private readonly UserArchiveMapper $userArchiveMapper,
29+
private readonly ShareMapper $shareMapper,
30+
private readonly PermissionsService $permissionsService,
31+
private readonly LoggerInterface $logger,
32+
) {
33+
}
34+
35+
/**
36+
* Remove archive overrides of the given users on all nodes shared with
37+
* the given receiver, for every node the user can no longer access.
38+
*
39+
* Called when a user is removed from a group or from a circle.
40+
*
41+
* @param string[] $userIds
42+
*/
43+
public function cleanupAfterMembershipLoss(array $userIds, string $receiver, string $receiverType): void {
44+
try {
45+
$nodes = $this->shareMapper->findNodesByReceiver($receiver, $receiverType);
46+
} catch (\Throwable $e) {
47+
$this->logNonFatal(__FUNCTION__, $e);
48+
return;
49+
}
50+
51+
$nodeIdsByType = [
52+
Application::NODE_TYPE_TABLE => [],
53+
Application::NODE_TYPE_CONTEXT => [],
54+
];
55+
foreach ($nodes as $node) {
56+
$nodeType = $this->shareNodeType2Const($node['nodeType']);
57+
if ($nodeType !== null) {
58+
$nodeIdsByType[$nodeType][] = $node['nodeId'];
59+
}
60+
}
61+
62+
foreach ($userIds as $userId) {
63+
foreach ($nodeIdsByType as $nodeType => $nodeIds) {
64+
if ($nodeIds === []) {
65+
continue;
66+
}
67+
try {
68+
$overrides = $this->userArchiveMapper->findAllOverridesForUser($userId, $nodeType, $nodeIds);
69+
} catch (\Throwable $e) {
70+
$this->logNonFatal(__FUNCTION__, $e);
71+
continue;
72+
}
73+
foreach (array_keys($overrides) as $nodeId) {
74+
$this->removeOverrideIfStale($userId, $nodeType, $nodeId);
75+
}
76+
}
77+
}
78+
}
79+
80+
/**
81+
* Remove the archive overrides of every user who can no longer access
82+
* the given node.
83+
*
84+
* Called when a group or circle receiver of the node is deleted, or when
85+
* a group or circle share of the node is removed.
86+
*/
87+
public function purgeNodeOverrides(int $nodeType, int $nodeId): void {
88+
try {
89+
$userIds = $this->userArchiveMapper->findUserIdsForNode($nodeType, $nodeId);
90+
} catch (\Throwable $e) {
91+
$this->logNonFatal(__FUNCTION__, $e);
92+
return;
93+
}
94+
95+
foreach ($userIds as $userId) {
96+
$this->removeOverrideIfStale($userId, $nodeType, $nodeId);
97+
}
98+
}
99+
100+
/**
101+
* Remove archive overrides that became stale because a share was deleted.
102+
*/
103+
public function cleanupAfterShareDeletion(Share $share): void {
104+
$nodeType = $this->shareNodeType2Const((string)$share->getNodeType());
105+
if ($nodeType === null) {
106+
return;
107+
}
108+
109+
$receiverType = $share->getReceiverType();
110+
if ($receiverType === ShareReceiverType::USER) {
111+
$this->removeOverrideIfStale((string)$share->getReceiver(), $nodeType, (int)$share->getNodeId());
112+
} elseif ($receiverType === ShareReceiverType::GROUP || $receiverType === ShareReceiverType::CIRCLE) {
113+
$this->purgeNodeOverrides($nodeType, (int)$share->getNodeId());
114+
}
115+
}
116+
117+
/**
118+
* Remove every archive override of a deleted user account.
119+
*/
120+
public function cleanupDeletedUser(string $userId): void {
121+
try {
122+
$this->userArchiveMapper->deleteAllForUser($userId);
123+
} catch (\Throwable $e) {
124+
$this->logNonFatal(__FUNCTION__, $e);
125+
}
126+
}
127+
128+
private function removeOverrideIfStale(string $userId, int $nodeType, int $nodeId): void {
129+
try {
130+
if ($this->hasAccess($userId, $nodeType, $nodeId)) {
131+
return;
132+
}
133+
$this->userArchiveMapper->deleteForUser($userId, $nodeType, $nodeId);
134+
} catch (\Throwable $e) {
135+
$this->logNonFatal(__FUNCTION__, $e);
136+
}
137+
}
138+
139+
private function hasAccess(string $userId, int $nodeType, int $nodeId): bool {
140+
if ($nodeType === Application::NODE_TYPE_CONTEXT) {
141+
return $this->permissionsService->canAccessContextById($nodeId, $userId);
142+
}
143+
return $this->permissionsService->canAccessNodeById($nodeType, $nodeId, $userId);
144+
}
145+
146+
private function shareNodeType2Const(string $nodeType): ?int {
147+
return match ($nodeType) {
148+
'table' => Application::NODE_TYPE_TABLE,
149+
'context' => Application::NODE_TYPE_CONTEXT,
150+
default => null,
151+
};
152+
}
153+
154+
private function logNonFatal(string $method, \Throwable $e): void {
155+
$this->logger->warning(static::class . ' - ' . $method . ': archive override cleanup failed: ' . $e->getMessage(), [
156+
'exception' => $e,
157+
]);
158+
}
159+
}

lib/Service/ShareService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public function __construct(
7171
private readonly IHasher $hasher,
7272
private readonly IShareManager $shareManager,
7373
private readonly FederationService $federationService,
74+
private readonly ArchiveCleanupService $archiveCleanupService,
7475
) {
7576
parent::__construct($logger, $userId, $permissionsService);
7677
}
@@ -731,6 +732,9 @@ public function delete(int $id): Share {
731732
$this->logger->error($e->getMessage(), ['exception' => $e]);
732733
throw new InternalError(static::class . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
733734
}
735+
736+
$this->archiveCleanupService->cleanupAfterShareDeletion($item);
737+
734738
return $item;
735739
}
736740

@@ -817,6 +821,7 @@ public function deleteForShareReview(int $id): void {
817821
if ($share->getNodeType() === 'context') {
818822
$this->contextNavigationMapper->deleteByShareId($share->getId());
819823
}
824+
$this->archiveCleanupService->cleanupAfterShareDeletion($share);
820825
}
821826

822827
public function deleteAllForTable(Table $table):void {

tests/stub.phpstub

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,31 @@ namespace OCP\Share\ShareReview\Events {
7171
namespace OCA\Circles\Model {
7272
class Circle {
7373
abstract public function getSingleId(): string {}
74+
/** @return list<Member> */
75+
abstract public function getInheritedMembers(): array {}
76+
}
77+
class Member {
78+
public const TYPE_SINGLE = 0;
79+
public const TYPE_USER = 1;
80+
public const TYPE_GROUP = 2;
81+
public const TYPE_MAIL = 4;
82+
public const TYPE_CONTACT = 8;
83+
public const TYPE_CIRCLE = 16;
84+
public const TYPE_APP = 10000;
85+
abstract public function getUserId(): string {}
86+
abstract public function getUserType(): int {}
87+
abstract public function getBasedOn(): ?Circle {}
7488
}
7589
}
7690

7791
namespace OCA\Circles\Events {
7892
class CircleDestroyedEvent extends \OCP\EventDispatcher\Event {
7993
public function getCircle(): \OCA\Circles\Model\Circle {}
8094
}
95+
class CircleMemberRemovedEvent extends \OCP\EventDispatcher\Event {
96+
public function getCircle(): \OCA\Circles\Model\Circle {}
97+
public function getMember(): ?\OCA\Circles\Model\Member {}
98+
}
8199
}
82100

83101
namespace OC\Collaboration\Reference {

0 commit comments

Comments
 (0)