diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 3f024948ce02c..4eb63bc3dfa6f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -854,6 +854,7 @@ 'OCP\\Share\\Events\\ShareDeletedEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', 'OCP\\Share\\Events\\ShareMovedEvent' => $baseDir . '/lib/public/Share/Events/ShareMovedEvent.php', + 'OCP\\Share\\Events\\ShareReviewAccessCheckEvent' => $baseDir . '/lib/public/Share/Events/ShareReviewAccessCheckEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => $baseDir . '/lib/public/Share/Events/ShareTransferredEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => $baseDir . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => $baseDir . '/lib/public/Share/Exceptions/AlreadySharedException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 65bb78d3ee6a2..c7f98536b3fe1 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -895,6 +895,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\Events\\ShareDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', 'OCP\\Share\\Events\\ShareMovedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareMovedEvent.php', + 'OCP\\Share\\Events\\ShareReviewAccessCheckEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareReviewAccessCheckEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareTransferredEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/AlreadySharedException.php', diff --git a/lib/public/Share/Events/ShareReviewAccessCheckEvent.php b/lib/public/Share/Events/ShareReviewAccessCheckEvent.php new file mode 100644 index 0000000000000..8689d1ecc2316 --- /dev/null +++ b/lib/public/Share/Events/ShareReviewAccessCheckEvent.php @@ -0,0 +1,130 @@ +dispatchTyped($event); + * if (!$event->isHandled() || !$event->isGranted()) { + * return false; // default-deny: no listener means no access + * } + * + * Semantics: + * - Default-deny: an unhandled event blocks the deletion. + * - 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 + */ +#[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; + } +} diff --git a/tests/lib/Share20/Events/ShareReviewAccessCheckEventTest.php b/tests/lib/Share20/Events/ShareReviewAccessCheckEventTest.php new file mode 100644 index 0000000000000..fad7dbae01401 --- /dev/null +++ b/tests/lib/Share20/Events/ShareReviewAccessCheckEventTest.php @@ -0,0 +1,96 @@ +makeEvent(); + + $this->assertFalse($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertNull($event->getReason()); + } + + public function testConstructorPayload(): void { + $event = new ShareReviewAccessCheckEvent('Deck', '99'); + + $this->assertSame('Deck', $event->getSourceName()); + $this->assertSame('99', $event->getShareId()); + } + + public function testGrantAccess(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + + $this->assertTrue($event->isHandled()); + $this->assertTrue($event->isGranted()); + $this->assertNull($event->getReason()); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testDenyAccess(): void { + $event = $this->makeEvent(); + $event->denyAccess('not in group'); + + $this->assertTrue($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertSame('not in group', $event->getReason()); + } + + public function testDenyStopsPropagation(): void { + $event = $this->makeEvent(); + $event->denyAccess('no access'); + + $this->assertTrue($event->isPropagationStopped()); + } + + public function testGrantDoesNotStopPropagation(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + + $this->assertFalse($event->isPropagationStopped()); + } + + public function testGrantThenDenyIsDenied(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + $event->denyAccess('revoked'); + + $this->assertFalse($event->isGranted()); + $this->assertSame('revoked', $event->getReason()); + $this->assertTrue($event->isPropagationStopped()); + } + + public function testDenyThenGrantRemainesDenied(): void { + $event = $this->makeEvent(); + $event->denyAccess('not allowed'); + $event->grantAccess(); // must be ignored — deny wins + + $this->assertFalse($event->isGranted()); + $this->assertSame('not allowed', $event->getReason()); + } + + public function testMultipleGrantsAreIdempotent(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + $event->grantAccess(); + + $this->assertTrue($event->isGranted()); + $this->assertFalse($event->isPropagationStopped()); + } +}