Skip to content
5 changes: 5 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,11 @@
'OCP\\Share\\IShareProviderSupportsAccept' => $baseDir . '/lib/public/Share/IShareProviderSupportsAccept.php',
'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\\IShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSource.php',
'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => $baseDir . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php',
'OCP\\Share\\ShareReview\\ShareReviewEntry' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewEntry.php',
'OCP\\Share\\ShareReview\\ShareReviewPermission' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPermission.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
5 changes: 5 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Share\\IShareProviderSupportsAccept' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderSupportsAccept.php',
'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\\IShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSource.php',
'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php',
'OCP\\Share\\ShareReview\\ShareReviewEntry' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewEntry.php',
'OCP\\Share\\ShareReview\\ShareReviewPermission' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPermission.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
165 changes: 165 additions & 0 deletions lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?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\Events;

use OCP\AppFramework\Attribute\Consumable;
use OCP\EventDispatcher\Event;

/**
* Authorization gate for deleting an app-managed share through a share-review app.
*
* Background: Apps such as Deck or Tables manage their own shares outside of
* the regular sharing backend ({@see \OCP\Share\IManager}). They can expose
* those shares to a share-review app — a compliance tool that lets designated
* operators audit and revoke shares across all apps — by implementing
* {@see \OCP\Share\ShareReview\IShareReviewSource}. When a share-review
* operator requests the deletion of such a share, the deletion is executed by
* the app that owns the share, not by the share-review app. The owning app
* has no way of knowing whether the acting user is actually authorized to
* perform share reviews — only the share-review app knows that. This event
* closes that gap: it lets the owning app ask "may the current user delete
* this share on behalf of a share review?" before deleting anything.
*
* Dispatched by: the app that owns the share, i.e. the
* {@see \OCP\Share\ShareReview\IShareReviewSource} implementation, at the
* beginning of its deleteShare() method:
*
* public function deleteShare(string $shareId): bool {
* $event = new ShareReviewAccessCheckEvent('MyApp', $shareId);
* $this->dispatcher->dispatchTyped($event);
* if (!$event->isHandled() || !$event->isGranted()) {
* return false; // default-deny: no listener means no access
* }
* // ... actually delete the share ...
* }
*
* Listened to by: the share-review app. Its listener decides whether the
* current user is an authorized share-review operator (e.g. the app is
* enabled for the user) and answers with grantAccess() or denyAccess():
*
* public function handle(Event $event): void {
* if (!$event instanceof ShareReviewAccessCheckEvent) {
* return;
* }
* if ($this->isShareReviewOperator()) {
* $event->grantAccess();
* } else {
* $event->denyAccess('User is not a share-review operator.');
* }
* }
*
* Apps that merely expose shares must not listen to this event; answering it
* is the responsibility of the share-review app that triggered the deletion.
*
* Semantics:
* - Default-deny: if no listener responds (isHandled() is false, e.g. no
* share-review app is installed), the dispatcher must not delete the share.
* - Deny wins: once denyAccess() is called, further grantAccess() calls are
* ignored and propagation is stopped immediately.
* - Multiple grants are harmless; the last listener to deny is authoritative.
*
* @since 34.0.2
*/
Comment thread
AndyScherzinger marked this conversation as resolved.
#[Consumable(since: '34.0.2')]
class ShareReviewAccessCheckEvent extends Event {

private bool $handled = false;
private bool $granted = false;
private ?string $reason = null;

/**
* @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.
*
* @since 34.0.2
*/
public function __construct(
private readonly string $sourceName,
private readonly string $shareId,
) {
parent::__construct();
}

/**
* Stable, non-translated identifier of the app that owns this share source.
*
* @since 34.0.2
*/
public function getSourceName(): string {
return $this->sourceName;
}

/**
* App-internal identifier of the share being deleted.
*
* @since 34.0.2
*/
public function getShareId(): string {
return $this->shareId;
}

/**
* Grant access to delete the share.
*
* Has no effect if denyAccess() was already called on this event — deny wins.
*
* @since 34.0.2
*/
public function grantAccess(): void {
if ($this->handled && !$this->granted) {
return; // deny wins — a prior denyAccess() cannot be escalated to a grant
}
$this->handled = true;
$this->granted = true;
}

/**
* Deny access and provide a human-readable reason.
*
* Stops event propagation immediately — no further listeners will run.
*
* @since 34.0.2
*/
public function denyAccess(string $reason): void {
$this->handled = true;
$this->granted = false;
$this->reason = $reason;
$this->stopPropagation();
}

/**
* Whether any listener has responded to this event.
*
* @since 34.0.2
*/
public function isHandled(): bool {
return $this->handled;
}

/**
* Whether access was granted.
*
* @since 34.0.2
*/
public function isGranted(): bool {
return $this->granted;
}

/**
* Human-readable denial reason, or null if access was granted or the event
* has not been handled yet.
*
* @since 34.0.2
*/
public function getReason(): ?string {
return $this->reason;
}
}
48 changes: 48 additions & 0 deletions lib/public/Share/ShareReview/IShareReviewSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?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;

/**
* Interface to be implemented by apps that want to expose their app-managed
* shares to a share-review app. Implementations are registered through
* {@see RegisterShareReviewSourceEvent} and resolved from the dependency
* injection container.
*
* @since 34.0.2
*/
#[Implementable(since: '34.0.2')]
interface IShareReviewSource {
/**
* The name of the app, used in the review table
*
* @since 34.0.2
*/
public function getName(): string;

/**
* Return all app-specific shares.
*
* The app name is added by the share-review app from getName().
*
* @return list<ShareReviewEntry>
*
* @since 34.0.2
*/
public function getShares(): array;

/**
* Delete an app-specific share.
*
* @since 34.0.2
*/
public function deleteShare(string $shareId): bool;
}
46 changes: 46 additions & 0 deletions lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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\Dispatchable;
use OCP\AppFramework\Attribute\Listenable;
use OCP\EventDispatcher\Event;

/**
* Event dispatched by a share-review app to collect share sources from other
* apps. Listeners register the class name of their {@see IShareReviewSource}
* implementation; the share-review app resolves it from the dependency
* injection container.
*
* @since 34.0.2
*/
#[Listenable(since: '34.0.2')]
#[Dispatchable(since: '34.0.2')]
class RegisterShareReviewSourceEvent extends Event {

/** @var array<int, class-string<IShareReviewSource>> */
private array $sources = [];

/**
* @param class-string<IShareReviewSource> $source
* @since 34.0.2
*/
public function registerSource(string $source): void {
$this->sources[] = $source;
}

/**
* @return array<int, class-string<IShareReviewSource>>
* @since 34.0.2
*/
public function getSources(): array {
return $this->sources;
}
}
64 changes: 64 additions & 0 deletions lib/public/Share/ShareReview/ShareReviewEntry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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\Consumable;
use OCP\Share\IShare;

/**
* Holds a single app-managed share as exposed to a share-review app through
* {@see IShareReviewSource::getShares()}.
*
* @since 34.0.2
*/
#[Consumable(since: '34.0.2')]
final class ShareReviewEntry {
/**
* @param string $id Unique app-specific identifier for the share, passed
* to {@see IShareReviewSource::deleteShare()}.
* @param string $object Name or title of the shared object, such as a
* file path or report name.
* @param string $initiator User ID of the initiator.
* @param IShare::TYPE_* $type {@see \OCP\Share\IShare} type of the share.
* @param string $recipient User ID of the owner or the token of a link.
* @param int $lastModifiedTimestamp Unix timestamp of the share's creation
* or last modification, whichever is
* later; used for sorting and for the
* new-since-last-review filter. Pass 0
* if the app tracks neither.
* @param list<ShareReviewPermission> $permissions Permissions granted by
* the share. An empty list
* means the share grants
* nothing beyond existing.
* @param string $action Optional deletion identifier override. An empty
* string means $id is used.
* @param bool $hasPassword Whether the share is password protected. Never
* the password itself.
* @param int|null $expirationTimestamp Optional expiration Unix timestamp
* of the share.
* @param string|null $parent Optional identifier of the parent share.
*
* @since 34.0.2
*/
public function __construct(
public readonly string $id,
public readonly string $object,
public readonly string $initiator,
public readonly int $type,
public readonly string $recipient,
public readonly int $lastModifiedTimestamp,
public readonly array $permissions = [],
public readonly string $action = '',
public readonly bool $hasPassword = false,
public readonly ?int $expirationTimestamp = null,
public readonly ?string $parent = null,
) {
}
}
Loading
Loading