Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/files/lib/Sharing/Source/NodeShareSourceType.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\Storage\ISharedStorage;
use OCP\IDBConnection;
use OCP\Interaction\InteractionResource;
use OCP\Interaction\Resources\NodeResource;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;

/**
Expand Down Expand Up @@ -87,4 +89,18 @@ public function handle(Event $event): void {
throw $exception;
}
}

#[\Override]
public function userHasDirectSharingAccessToSource(IUser $user, string $source): bool {
// TODO: cache nodes by id?
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$nodes = $userFolder->getById((int)$source);
foreach ($nodes as $node) {
if (!$node->getStorage() instanceof ISharedStorage && $node->isShareable()) {
return true;
}
}

return false;
}
}
48 changes: 44 additions & 4 deletions lib/private/Sharing/SharingBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -559,17 +559,37 @@
* @return list<Share>
*/
private function list(ShareAccessContext $accessContext, ?string $filterShareID, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array {
if ($filterSourceTypeClass) {
$filterSourceType = $this->registry->getSourceTypes()[$filterSourceTypeClass] ?? null;
if ($filterSourceType === null) {
throw new RuntimeException('The source type is not registered: ' . $filterSourceTypeClass);
}
} else {
$filterSourceType = null;
}

/** @var array<class-string<IShareRecipientType>, list<string>> $recipientTypeValues */
$recipientTypeValues = [];

/** @var list<IQueryBuilder> $queries */
$queries = [];
if ($accessContext->overrideChecks) {
$queries[] = $this->connection->getQueryBuilder();
$userHasDirectAccess = false;
} else {
if ($filterSourceType && $filterSourceTypeValue !== null && $accessContext->currentUser instanceof IUser) {
$userHasDirectAccess = $filterSourceType->userHasDirectSharingAccessToSource($accessContext->currentUser, $filterSourceTypeValue);

Check failure on line 581 in lib/private/Sharing/SharingBackend.php

View workflow job for this annotation

GitHub Actions / static-code-analysis-strict

ArgumentTypeCoercion

lib/private/Sharing/SharingBackend.php:581:111: ArgumentTypeCoercion: Argument 2 of NCU\Sharing\Source\IShareSourceType::userHasDirectSharingAccessToSource expects non-empty-string, but parent type string provided (see https://psalm.dev/193)
} else {
$userHasDirectAccess = false;
}

if ($accessContext->currentUser instanceof IUser) {
$qb = $this->connection->getQueryBuilder();
$qb->where($qb->expr()->eq('s.owner_user_id', $qb->createNamedParameter($accessContext->currentUser->getUID())));
// if the access user has "direct share access" we don't filter by owner, but instead validate that all share sources are accessible
if (!$userHasDirectAccess) {
$qb->where($qb->expr()->eq('s.owner_user_id', $qb->createNamedParameter($accessContext->currentUser->getUID())));
}

$queries[] = $qb;
}

Expand All @@ -581,7 +601,8 @@
}

// Do not add a query if no recipients matched, otherwise all shares will be returned.
if ($recipientTypeValues !== []) {
// If the user has "direct" access, we already get all the shares, so no need to run an extra query for recipients
if ($recipientTypeValues !== [] && !$userHasDirectAccess) {
$qb = $this->connection->getQueryBuilder();
$qb->innerJoin('s', 'sharing_share_recipients', 'sr', $qb->expr()->andX(
$qb->expr()->eq('s.state', $qb->createNamedParameter(ShareState::Active->value)),
Expand Down Expand Up @@ -631,7 +652,7 @@
$qb->andWhere($qb->expr()->eq('s.id', $qb->createNamedParameter($filterShareID)));
}

if ($filterSourceTypeClass !== null) {
if ($filterSourceType !== null) {
$sourceTypeFilters = [
$qb->expr()->eq('s.id', 'ss.share_id'),
$qb->expr()->eq('ss.source_class', $qb->createNamedParameter($filterSourceTypeClass)),
Expand Down Expand Up @@ -802,7 +823,9 @@
if ($share['owner']->isCurrentUser($accessContext)) {
continue;
}

if ($userHasDirectAccess) {
continue;
}
$isAnyMatchingRecipient = false;
foreach ($share['recipients'] as &$recipient) {
$isMatchingRecipient = false;
Expand Down Expand Up @@ -962,6 +985,23 @@
$share['permissions'],
), $shares);

// when listing shares for a source, we also return any non-owned share if the user has "direct" access to the source
// but we do need to validate that the user has "direct" access to *all* of the sources in the share, not just one
if (!$accessContext->overrideChecks && $filterSourceType && $filterSourceTypeValue !== null && $accessContext->currentUser instanceof IUser) {
$shares = array_filter($shares, function (Share $share) use ($accessContext): bool {
if (!$share->owner->isCurrentUser($accessContext) && count($share->sources) > 1) {
foreach ($share->sources as $source) {
$sourceType = $this->registry->getSourceTypes()[$source->class];
if (!$sourceType->userHasDirectSharingAccessToSource($accessContext->currentUser, $source->value)) {
return false;
}
}
}

return true;
});
}

if (!$accessContext->overrideChecks) {
$filterPropertyTypes = array_filter($registryPropertyTypes, static fn (ISharePropertyType $propertyType): bool => $propertyType instanceof ISharePropertyTypeFilter);
if ($filterPropertyTypes !== []) {
Expand Down
2 changes: 1 addition & 1 deletion lib/private/Sharing/SharingManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ private function validateInteraction(ShareAccessContext $accessContext, Share $s
$action = new ShareAction(null, array_values(array_map(static fn (SharePermission $permission): string => $permission->class, $share->getEnabledPermissions())));

$usersToCheck = [];
if ($share->owner->instance === null && ($ownerUser = $this->userManager->get($share->owner->userId)) !== null) {
if ($share->owner->instance === null && ($ownerUser = $this->userManager->get($share->owner->userId)) instanceof IUser) {
$usersToCheck[] = $ownerUser;
}

Expand Down
10 changes: 10 additions & 0 deletions lib/unstable/Sharing/Source/IShareSourceType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use NCU\Sharing\Icon\ShareIconURL;
use OCP\AppFramework\Attribute\Implementable;
use OCP\Interaction\InteractionResource;
use OCP\IUser;
use OCP\L10N\IFactory;

/**
Expand Down Expand Up @@ -57,4 +58,13 @@
* @experimental 35.0.0
*/
public function getSourceInteractionResource(string $userId, string $source): InteractionResource;

/**
* Check if a user has access to the specified source without taking sharing into account, and has sufficient permissions to create shares.
*
* All users with "direct" access to the source will be able to see and manage shares made by other users for the source.
*
* @param non-empty-string $source
*/
public function userHasDirectSharingAccessToSource(IUser $user, string $source): bool;

Check failure on line 69 in lib/unstable/Sharing/Source/IShareSourceType.php

View workflow job for this annotation

GitHub Actions / static-code-analysis-ncu

InvalidDocblock

lib/unstable/Sharing/Source/IShareSourceType.php:69:2: InvalidDocblock: @experimental is required for methods in NCU. (see https://psalm.dev/008)
}
60 changes: 58 additions & 2 deletions tests/lib/Sharing/AbstractSharingManagerTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ abstract protected function getShares(ShareAccessContext $accessContext, ?string

protected IUser $user2;

protected TestShareSourceType1 $shareSourceType1;

protected TestShareSourceType2 $shareSourceType2;

#[\Override]
public function setUp(): void {
parent::setUp();
Expand Down Expand Up @@ -112,8 +116,11 @@ public function setUp(): void {

$this->registry = Server::get(ISharingRegistry::class);
$this->registry->clear();
$this->registry->registerSourceType(new TestShareSourceType1(['source1' => 'Source 1']));
$this->registry->registerSourceType(new TestShareSourceType2(['source2' => 'Source 2']));

$this->shareSourceType1 = new TestShareSourceType1(['source1' => 'Source 1']);
$this->registry->registerSourceType($this->shareSourceType1);
$this->shareSourceType2 = new TestShareSourceType2(['source2' => 'Source 2']);
$this->registry->registerSourceType($this->shareSourceType2);
$this->registry->registerRecipientType(new TestShareRecipientType1(
[
'recipient1' => 'Recipient 1',
Expand Down Expand Up @@ -4032,4 +4039,53 @@ public function testInitiatorDeleted(): void {
],
], $share['recipients']);
}

public function testGetWithDirectAccess(): void {
$accessContext = new ShareAccessContext($this->owner);

$before = $this->manager->generateTimestamp();
$this->dbConnection->beginTransaction();
$id = $this->manager->createShare($accessContext);
$this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1'));
$this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null));
$this->manager->getShare($accessContext, $id);
$this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true));
$this->manager->updateShareState($accessContext, $id, ShareState::Active);

$this->shareSourceType1->userAccess[$this->owner->getUID()] = ['source1'];

$this->dbConnection->commit();

$after = $this->manager->generateTimestamp();

// user2 has no direct access, no shares
$shares = $this->getShares(new ShareAccessContext(currentUser: $this->user2), TestShareSourceType1::class, 'source1', null, null);
$this->assertCount(0, $shares);

// give user2 direct access, can see shares
$this->shareSourceType1->userAccess[$this->user2->getUID()] = ['source1'];
$shares = $this->getShares(new ShareAccessContext(currentUser: $this->user2), TestShareSourceType1::class, 'source1', null, null);

$this->assertCount(1, $shares);
$share = $shares[0];

$this->assertGreaterThanOrEqual($before, $share['last_updated']);
$this->assertLessThanOrEqual($after, $share['last_updated']);
$this->assertEquals([
'user_id' => 'owner',
'instance' => null,
'display_name' => 'Owner',
'icon' => [
'light' => 'http://localhost/index.php/avatar/owner/64',
'dark' => 'http://localhost/index.php/avatar/owner/64/dark',
],
], $share['owner']);

// add a source that user2 doesn't have access to, can't see share anymore
$this->dbConnection->beginTransaction();
$this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType2::class, 'source2'));
$this->dbConnection->commit();
$shares = $this->getShares(new ShareAccessContext(currentUser: $this->user2), TestShareSourceType1::class, 'source1', null, null);
$this->assertCount(0, $shares);
}
}
9 changes: 9 additions & 0 deletions tests/lib/Sharing/TestShareSourceType1.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
use NCU\Sharing\Icon\ShareIconURL;
use NCU\Sharing\Source\IShareSourceType;
use OCP\Interaction\InteractionResource;
use OCP\IUser;
use OCP\L10N\IFactory;

class TestShareSourceType1 implements IShareSourceType {
public function __construct(
/** @var array<string, non-empty-string> $validSources */
private readonly array $validSources,
/** @var array<non-empty-string, non-empty-string[]> $validSources */
public array $userAccess = [],
) {
}

Expand Down Expand Up @@ -48,4 +51,10 @@ public function getSourceIcon(string $source): null|ShareIconSVG|ShareIconURL {
public function getSourceInteractionResource(string $userId, string $source): InteractionResource {
return new TestInteractionResource($source);
}

#[\Override]
public function userHasDirectSharingAccessToSource(IUser $user, string $source): bool {
$userSources = $this->userAccess[$user->getUID()] ?? [];
return in_array($source, $userSources);
}
}
Loading