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
7 changes: 7 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -959,10 +959,17 @@
'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => $baseDir . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php',
'OCP\\Share\\IShareProviderWithNotification' => $baseDir . '/lib/public/Share/IShareProviderWithNotification.php',
'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => $baseDir . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php',
'OCP\\Share\\ShareReview\\IPaginatedShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php',
'OCP\\Share\\ShareReview\\IShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSource.php',
'OCP\\Share\\ShareReview\\IShareReviewSourceRemediation' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php',
'OCP\\Share\\ShareReview\\IShareReviewSourceSnapshot' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSourceSnapshot.php',
'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => $baseDir . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php',
'OCP\\Share\\ShareReview\\ShareReviewActionContext' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewActionContext.php',
'OCP\\Share\\ShareReview\\ShareReviewCounts' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewCounts.php',
'OCP\\Share\\ShareReview\\ShareReviewEntry' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewEntry.php',
'OCP\\Share\\ShareReview\\ShareReviewPage' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPage.php',
'OCP\\Share\\ShareReview\\ShareReviewPermission' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPermission.php',
'OCP\\Share\\ShareReview\\ShareReviewQuery' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewQuery.php',
'OCP\\Snowflake\\ISnowflakeDecoder' => $baseDir . '/lib/public/Snowflake/ISnowflakeDecoder.php',
'OCP\\Snowflake\\ISnowflakeGenerator' => $baseDir . '/lib/public/Snowflake/ISnowflakeGenerator.php',
'OCP\\Snowflake\\Snowflake' => $baseDir . '/lib/public/Snowflake/Snowflake.php',
Expand Down
7 changes: 7 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1000,10 +1000,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php',
'OCP\\Share\\IShareProviderWithNotification' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderWithNotification.php',
'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php',
'OCP\\Share\\ShareReview\\IPaginatedShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php',
'OCP\\Share\\ShareReview\\IShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSource.php',
'OCP\\Share\\ShareReview\\IShareReviewSourceRemediation' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php',
'OCP\\Share\\ShareReview\\IShareReviewSourceSnapshot' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSourceSnapshot.php',
'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php',
'OCP\\Share\\ShareReview\\ShareReviewActionContext' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewActionContext.php',
'OCP\\Share\\ShareReview\\ShareReviewCounts' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewCounts.php',
'OCP\\Share\\ShareReview\\ShareReviewEntry' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewEntry.php',
'OCP\\Share\\ShareReview\\ShareReviewPage' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPage.php',
'OCP\\Share\\ShareReview\\ShareReviewPermission' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPermission.php',
'OCP\\Share\\ShareReview\\ShareReviewQuery' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewQuery.php',
'OCP\\Snowflake\\ISnowflakeDecoder' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeDecoder.php',
'OCP\\Snowflake\\ISnowflakeGenerator' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeGenerator.php',
'OCP\\Snowflake\\Snowflake' => __DIR__ . '/../../..' . '/lib/public/Snowflake/Snowflake.php',
Expand Down
131 changes: 127 additions & 4 deletions lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use OCP\EventDispatcher\Event;

/**
* Authorization gate for deleting an app-managed share through a share-review app.
* Authorization gate for acting on an app-managed share through a share-review
* app: deleting it, remediating it (password, expiration) or restoring it from
* a snapshot.
*
* Background: Apps such as Deck or Tables manage their own shares outside of
* the regular sharing backend ({@see \OCP\Share\IManager}). They can expose
Expand Down Expand Up @@ -65,10 +67,83 @@
* ignored and propagation is stopped immediately.
* - Multiple grants are harmless; the last listener to deny is authoritative.
*
* Actions and scopes (since 35.0.1): the same gate covers every operation a
* share-review app can request, so the owning app dispatches one event type
* and the share-review app answers it from one listener:
* - ACTION_DELETE — dispatched by {@see \OCP\Share\ShareReview\IShareReviewSource::deleteShare()}.
* This is the default and the only action of the 34.0.2 event.
* - ACTION_REMEDIATE — dispatched by the mutators of
* {@see \OCP\Share\ShareReview\IShareReviewSourceRemediation} before a
* password or expiration date is changed.
* - ACTION_RESTORE — dispatched by
* {@see \OCP\Share\ShareReview\IShareReviewSourceSnapshot::restoreShare()}
* before a share is re-created from a snapshot.
* - SCOPE_OPERATOR (default) — the acting user is a share-review operator
* reviewing the whole instance; the listener grants based on operator
* membership, exactly as for the 34.0.2 event.
* - SCOPE_SELF — the acting user reviews their own shares (a personal
* self-audit). The listener must additionally verify, e.g. through
* {@see \OCP\Share\ShareReview\IPaginatedShareReviewSource::getShare()},
* that the acting user is the initiator of the share before granting.
* For ACTION_RESTORE the share no longer exists, so initiatorship must
* be verified against the initiator recorded with the snapshot instead.
* - The acting user defaults to the session user (null); a background job
* acting for a user passes the user id explicitly. The listener must use
* getActingUserId() when set instead of the session.
* - Both values reach the owning app through the
* {@see \OCP\Share\ShareReview\ShareReviewActionContext} a share-review
* app passes to the mutators of the 35.0.1 source interfaces; the owning
* app forwards them into this event verbatim and never decides them itself.
* Listeners that predate 35.0.1 see every action as a plain access check and
* grant or deny by operator membership: non-operators stay denied (fail
* closed), but for operators the new actions extend the granted capability
* set — ACTION_REMEDIATE includes removing a link share's password, which
* can expose content deletion never could. A listener that distinguishes
* reviewers with delete-only rights must check getAction() and deny actions
* it does not recognize.
*
* @since 34.0.2
*/
#[Consumable(since: '34.0.2')]
class ShareReviewAccessCheckEvent extends Event {
/**
* The share is about to be deleted
* @since 35.0.1
*/
public const ACTION_DELETE = 'delete';
/**
* The share's password or expiration date is about to be changed
* @since 35.0.1
*/
public const ACTION_REMEDIATE = 'remediate';
/**
* The share is about to be re-created from a snapshot
* @since 35.0.1
*/
public const ACTION_RESTORE = 'restore';

/**
* The acting user reviews the whole instance as a share-review operator
* @since 35.0.1
*/
public const SCOPE_OPERATOR = 'operator';
/**
* The acting user reviews their own shares only
* @since 35.0.1
*/
public const SCOPE_SELF = 'self';

/**
* All valid values for $action
* @since 35.0.1
*/
public const ACTIONS = [self::ACTION_DELETE, self::ACTION_REMEDIATE, self::ACTION_RESTORE];

/**
* All valid values for $scope
* @since 35.0.1
*/
public const SCOPES = [self::SCOPE_OPERATOR, self::SCOPE_SELF];

private bool $handled = false;
private bool $granted = false;
Expand All @@ -77,15 +152,33 @@ class ShareReviewAccessCheckEvent extends Event {
/**
* @param string $sourceName Stable, non-translated identifier for the app
* registering the share source (e.g. 'Deck', 'Tables').
* @param string $shareId App-internal identifier of the share being deleted.
* @param string $shareId App-internal identifier of the share being acted on.
* @param self::ACTION_* $action The operation being authorized (since 35.0.1).
* @param string|null $actingUserId The user the operation is performed
* for; null means the session user
* (since 35.0.1).
* @param self::SCOPE_* $scope Whether the acting user acts as an operator
* over all shares or on their own shares only
* (since 35.0.1).
*
* @throws \InvalidArgumentException on an unknown $action or $scope
*
* @since 34.0.2
*/
public function __construct(
private readonly string $sourceName,
private readonly string $shareId,
private readonly string $action = self::ACTION_DELETE,
private readonly ?string $actingUserId = null,
private readonly string $scope = self::SCOPE_OPERATOR,
) {
parent::__construct();
if (!in_array($action, self::ACTIONS, true)) {
throw new \InvalidArgumentException('Unknown share review action');
}
if (!in_array($scope, self::SCOPES, true)) {
throw new \InvalidArgumentException('Unknown share review scope');
}
}

/**
Expand All @@ -98,7 +191,7 @@ public function getSourceName(): string {
}

/**
* App-internal identifier of the share being deleted.
* App-internal identifier of the share being acted on.
*
* @since 34.0.2
*/
Expand All @@ -107,7 +200,37 @@ public function getShareId(): string {
}

/**
* Grant access to delete the share.
* The operation being authorized, one of the ACTION_* constants.
*
* @return self::ACTION_*
* @since 35.0.1
*/
public function getAction(): string {
return $this->action;
}

/**
* The user the operation is performed for, or null for the session user.
*
* @since 35.0.1
*/
public function getActingUserId(): ?string {
return $this->actingUserId;
}

/**
* Whether the acting user acts as an operator over all shares or on their
* own shares only, one of the SCOPE_* constants.
*
* @return self::SCOPE_*
* @since 35.0.1
*/
public function getScope(): string {
return $this->scope;
}

/**
* Grant access to perform the action on the share.
*
* Has no effect if denyAccess() was already called on this event — deny wins.
*
Expand Down
134 changes: 134 additions & 0 deletions lib/public/Share/ShareReview/IPaginatedShareReviewSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Share\ShareReview;

use OCP\AppFramework\Attribute\Implementable;
use OCP\Share\IShare;

/**
* A share-review source that can list its shares page by page, with sorting,
* search, filters and counts evaluated by the app itself — in SQL, so that
* instances with very many shares stay usable.
*
* Extends {@see IShareReviewSource}, which listed everything at once; a
* share-review app discovers this capability through `instanceof` and falls
* back to {@see IShareReviewSource::getShares()} for sources that do not
* implement it yet. Implementations register exactly like before, through
* {@see RegisterShareReviewSourceEvent}.
*
* Contract for all query methods (see {@see ShareReviewQuery} for the full
* field-by-field semantics):
* - Grouped counts ({@see countSharesByType()}, {@see countSharesByInitiator()})
* are single GROUP BY scans over the filtered rows, never a count per group
* issued in a loop.
* - Display enrichment (display names, path resolution) must only ever run
* for the entries a page actually returns, never for counted or filtered
* rows.
* - The sort and pagination fields of the query are ignored by the count
* methods; all search and filter fields apply to every method.
* - Sort fields resolve through a fixed whitelist, substring inputs are
* escaped for LIKE, and every value is bound as a query parameter.
*
* @since 35.0.1
*/
#[Implementable(since: '35.0.1')]
interface IPaginatedShareReviewSource extends IShareReviewSource {
/**
* Localized label of this source, e.g. for a tab title. Unlike
* {@see IShareReviewSource::getName()}, which must stay a stable,
* non-translated id, this may be translated. It is declared here because
* adding a method to the released {@see IShareReviewSource} would break its
* existing implementations.
*
* @since 35.0.1
*/
public function getDisplayName(): string;

/**
* Return one page of shares matching the query, with the counts of the
* whole query.
*
* @since 35.0.1
*/
public function queryShares(ShareReviewQuery $query): ShareReviewPage;

/**
* Return the total and filtered counts for the query without fetching any
* rows. The query's limit, offset, sortField and sortDescending are
* ignored.
*
* @since 35.0.1
*/
public function countShares(ShareReviewQuery $query): ShareReviewCounts;

/**
* Return the filtered count per share type in one grouped scan. All
* search and filter fields of the query apply (including shareTypes);
* limit, offset and sorting are ignored. Types with a count of zero are
* omitted.
*
* @return array<IShare::TYPE_*, int> map of share type to filtered count
*
* @since 35.0.1
*/
public function countSharesByType(ShareReviewQuery $query): array;

/**
* Return the filtered count per initiator for the initiators with the most
* shares, in one grouped scan. All search and filter fields of the query
* apply; limit, offset and sorting are ignored. The result is ordered by
* count descending, then by initiator id ascending, and holds at most
* $limit entries; initiators without a matching share are omitted.
*
* Serves aggregate views such as "top sharers" and the discovery of
* initiators that no longer exist as users, which a consumer cannot derive
* from paging without reading every share.
*
* @param int $limit Maximum number of initiators returned,
* 1..{@see ShareReviewQuery::MAX_LIMIT}.
* @return array<string, int> map of initiator id to filtered count
*
* @throws \InvalidArgumentException on a $limit outside 1..MAX_LIMIT
*
* @since 35.0.1
*/
public function countSharesByInitiator(ShareReviewQuery $query, int $limit): array;

/**
* Look up a single share by its deletion identifier — the value of
* {@see ShareReviewEntry::$action} if non-empty, else
* {@see ShareReviewEntry::$id} — i.e. the same id
* {@see IShareReviewSource::deleteShare()} accepts.
*
* @return ShareReviewEntry|null null if no such share exists
*
* @since 35.0.1
*/
public function getShare(string $shareId): ?ShareReviewEntry;

/**
* Delete a share, forwarding the action context into the access check.
* Identical to {@see IShareReviewSource::deleteShare()} except that the
* {@see \OCP\Share\ShareReview\Events\ShareReviewAccessCheckEvent} an
* implementation dispatches MUST carry $context->actingUserId and
* $context->scope. A null context means the session user acts as an
* operator, exactly like the 34.0.2 method.
*
* @param string $shareId The deletion identifier, as accepted by
* {@see IShareReviewSource::deleteShare()}.
* @param ShareReviewActionContext|null $context Who acts, and in which
* scope.
* @return bool whether the share was deleted
*
* @since 35.0.1
*/
#[\Override]
public function deleteShare(string $shareId, ?ShareReviewActionContext $context = null): bool;
}
17 changes: 15 additions & 2 deletions lib/public/Share/ShareReview/IShareReviewSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@
* {@see RegisterShareReviewSourceEvent} and resolved from the dependency
* injection container.
*
* Sources with many shares should implement the extending
* {@see IPaginatedShareReviewSource}, which lists shares page by page with
* sorting, search, filters and counts instead of everything at once.
*
* @since 34.0.2
*/
#[Implementable(since: '34.0.2')]
interface IShareReviewSource {
/**
* The name of the app, used in the review table
* Stable, non-translated identifier of this source, e.g. 'Deck'. Used as
* the key of the source in the review app (tab id, per-source review
* state) and as the source name of
* {@see Events\ShareReviewAccessCheckEvent}, so it must never change and
* must not be translated. Use
* {@see IPaginatedShareReviewSource::getDisplayName()} for a localized
* label.
*
* @since 34.0.2
*/
Expand All @@ -31,7 +41,10 @@ public function getName(): string;
/**
* Return all app-specific shares.
*
* The app name is added by the share-review app from getName().
* The app name is added by the share-review app from getName(). A source
* implementing {@see IPaginatedShareReviewSource} is queried page by page
* through queryShares() instead; it may implement this method by iterating
* over all pages.
*
* @return list<ShareReviewEntry>
*
Expand Down
Loading
Loading