Skip to content
Merged
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
28 changes: 27 additions & 1 deletion .drone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,32 @@ trigger:
- pull_request
- push

---
kind: pipeline
name: integration-caldav-delegation

steps:
- name: submodules
image: ghcr.io/nextcloud/continuous-integration-alpine-git:latest
commands:
- git submodule update --init
- name: integration-caldav-delegation
image: ghcr.io/nextcloud/continuous-integration-integration-php7.4:latest
commands:
- curl -O -L https://getcomposer.org/download/2.9.2/composer.phar && chmod +x composer.phar && mv composer.phar /usr/local/bin/composer
- bash tests/drone-run-integration-tests.sh || exit 0
- ./occ maintenance:install --admin-pass=admin --data-dir=/dev/shm/nc_int
- cd build/integration
- ./run.sh features/caldav-delegation.feature

trigger:
branch:
- master
- stable*
event:
- pull_request
- push

---
kind: pipeline
name: integration-comments
Expand Down Expand Up @@ -2034,6 +2060,6 @@ trigger:
- push
---
kind: signature
hmac: 0c4d3ce2e129d533e7f69584b295db9d7e3fbf3324e96499f85fa68197b8da61
hmac: 38ca65cb4503091595c6e8738015c61b4623b74f0e789daf312389613d87879a

...
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
'OCA\\DAV\\CalDAV\\Outbox' => $baseDir . '/../lib/CalDAV/Outbox.php',
'OCA\\DAV\\CalDAV\\Plugin' => $baseDir . '/../lib/CalDAV/Plugin.php',
'OCA\\DAV\\CalDAV\\Principal\\Collection' => $baseDir . '/../lib/CalDAV/Principal/Collection.php',
'OCA\\DAV\\CalDAV\\Principal\\ProxyRead' => $baseDir . '/../lib/CalDAV/Principal/ProxyRead.php',
'OCA\\DAV\\CalDAV\\Principal\\ProxyWrite' => $baseDir . '/../lib/CalDAV/Principal/ProxyWrite.php',
'OCA\\DAV\\CalDAV\\Principal\\User' => $baseDir . '/../lib/CalDAV/Principal/User.php',
'OCA\\DAV\\CalDAV\\Proxy\\Proxy' => $baseDir . '/../lib/CalDAV/Proxy/Proxy.php',
'OCA\\DAV\\CalDAV\\Proxy\\ProxyMapper' => $baseDir . '/../lib/CalDAV/Proxy/ProxyMapper.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Outbox' => __DIR__ . '/..' . '/../lib/CalDAV/Outbox.php',
'OCA\\DAV\\CalDAV\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Plugin.php',
'OCA\\DAV\\CalDAV\\Principal\\Collection' => __DIR__ . '/..' . '/../lib/CalDAV/Principal/Collection.php',
'OCA\\DAV\\CalDAV\\Principal\\ProxyRead' => __DIR__ . '/..' . '/../lib/CalDAV/Principal/ProxyRead.php',
'OCA\\DAV\\CalDAV\\Principal\\ProxyWrite' => __DIR__ . '/..' . '/../lib/CalDAV/Principal/ProxyWrite.php',
'OCA\\DAV\\CalDAV\\Principal\\User' => __DIR__ . '/..' . '/../lib/CalDAV/Principal/User.php',
'OCA\\DAV\\CalDAV\\Proxy\\Proxy' => __DIR__ . '/..' . '/../lib/CalDAV/Proxy/Proxy.php',
'OCA\\DAV\\CalDAV\\Proxy\\ProxyMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Proxy/ProxyMapper.php',
Expand Down
23 changes: 23 additions & 0 deletions apps/dav/lib/CalDAV/Principal/ProxyRead.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\CalDAV\Principal;

use Sabre\DAVACL;

class ProxyRead extends \Sabre\CalDAV\Principal\ProxyRead implements DAVACL\IACL {
use DAVACL\ACLTrait;

/**
* @inheritDoc
*/
public function getOwner() {
return $this->principalInfo['uri'];
}
}
23 changes: 23 additions & 0 deletions apps/dav/lib/CalDAV/Principal/ProxyWrite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\CalDAV\Principal;

use Sabre\DAVACL;

class ProxyWrite extends \Sabre\CalDAV\Principal\ProxyWrite implements DAVACL\IACL {
use DAVACL\ACLTrait;

/**
* @inheritDoc
*/
public function getOwner() {
return $this->principalInfo['uri'];
}
}
40 changes: 40 additions & 0 deletions apps/dav/lib/CalDAV/Principal/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,44 @@ public function getACL() {
];
return $acl;
}

/**
* Returns a specific child node, referenced by its name.
*
* @param string $name
*
* @return \Sabre\DAV\INode
*/
public function getChild($name) {
$principal = $this->principalBackend->getPrincipalByPath($this->getPrincipalURL() . '/' . $name);
if (!$principal) {
throw new \Sabre\DAV\Exception\NotFound("Node with name $name was not found");
}
if ($name === 'calendar-proxy-read') {
return new ProxyRead($this->principalBackend, $this->principalProperties);
}

if ($name === 'calendar-proxy-write') {
return new ProxyWrite($this->principalBackend, $this->principalProperties);
}

throw new \Sabre\DAV\Exception\NotFound("Node with name $name was not found");
}

/**
* Returns an array with all the child nodes.
*
* @return \Sabre\DAV\INode[]
*/
public function getChildren() {
$r = [];
if ($this->principalBackend->getPrincipalByPath($this->getPrincipalURL() . '/calendar-proxy-read')) {
$r[] = new ProxyRead($this->principalBackend, $this->principalProperties);
}
if ($this->principalBackend->getPrincipalByPath($this->getPrincipalURL() . '/calendar-proxy-write')) {
$r[] = new ProxyWrite($this->principalBackend, $this->principalProperties);
}

return $r;
}
}
134 changes: 134 additions & 0 deletions build/integration/features/bootstrap/CalDavContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ public function afterScenario() {
}
}

/** @AfterScenario @caldav-delegation */
public function afterDelegationScenario() {
foreach (['calendar-proxy-read', 'calendar-proxy-write'] as $proxyType) {
try {
$propPatch = new \Sabre\DAV\Xml\Request\PropPatch();
$propPatch->properties = ['{DAV:}group-member-set' => new \Sabre\DAV\Xml\Property\Href([])];
$xml = new \Sabre\Xml\Service();
$body = $xml->write('{DAV:}propertyupdate', $propPatch, '/');
$this->client->request(
'PROPPATCH',
$this->baseUrl . '/remote.php/dav/principals/users/admin/' . $proxyType,
[
'headers' => ['Content-Type' => 'application/xml; charset=UTF-8'],
'body' => $body,
'auth' => ['admin', 'admin'],
]
);
} catch (\GuzzleHttp\Exception\ClientException $e) {
}
}
}

/**
* @When :user requests calendar :calendar on the endpoint :endpoint
* @param string $user
Expand All @@ -104,6 +126,80 @@ public function requestsCalendar($user, $calendar, $endpoint) {
}
}

/**
* @Then The CalDAV response should contain a property :key
* @throws \Exception
*/
public function theCaldavResponseShouldContainAProperty(string $key) {
/** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */
$multiStatus = $this->responseXml['value'];
$responses = $multiStatus->getResponses()[0]->getResponseProperties();
if (!isset($responses[200])) {
throw new \Exception(
sprintf(
'Expected code 200 got [%s]',
implode(',', array_keys($responses)),
)
);
}

$props = $responses[200];
if (!array_key_exists($key, $props)) {
throw new \Exception(
sprintf(
'Expected property %s in %s',
$key,
json_encode($props, JSON_PRETTY_PRINT),
)
);
}
}

/**
* @Then The CalDAV response should contain an href :href
* @throws \Exception
*/
public function theCaldavResponseShouldContainAnHref(string $href) {
/** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */
$multiStatus = $this->responseXml['value'];
foreach ($multiStatus->getResponses() as $response) {
if ($response->getHref() === $href) {
return;
}
}
throw new \Exception(
sprintf(
'Expected href %s not found in response',
$href,
)
);
}

/**
* @Then The CalDAV response should be multi status
* @throws \Exception
*/
public function theCaldavResponseShouldBeMultiStatus() {
if ($this->response->getStatusCode() !== 207) {
throw new \Exception(
sprintf(
'Expected code 207 got %s',
$this->response->getStatusCode()
)
);
}

$body = $this->response->getBody()->getContents();
if ($body && substr($body, 0, 1) === '<') {
$reader = new Sabre\Xml\Reader();
$reader->xml($body);
$reader->elementMap['{DAV:}multistatus'] = \Sabre\DAV\Xml\Response\MultiStatus::class;
$reader->elementMap['{DAV:}response'] = \Sabre\DAV\Xml\Element\Response::class;
$reader->elementMap['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'] = \Sabre\DAV\Xml\Property\Href::class;
$this->responseXml = $reader->parse();
}
}

/**
* @Then The CalDAV HTTP status code should be :code
* @param int $code
Expand Down Expand Up @@ -233,4 +329,42 @@ public function t($amount) {
);
}
}
/**
* @Given :user updates property :key to href :value of principal :principal on the endpoint :endpoint
*/
public function updatesHrefPropertyOfPrincipal(
string $user,
string $key,
string $value,
string $principal,
string $endpoint
) {
$davUrl = $this->baseUrl . $endpoint . $principal;
$password = ($user === 'admin') ? 'admin' : '123456';

$propPatch = new \Sabre\DAV\Xml\Request\PropPatch();
$propPatch->properties = [$key => new \Sabre\DAV\Xml\Property\Href($value)];

$xml = new \Sabre\Xml\Service();
$body = $xml->write('{DAV:}propertyupdate', $propPatch, '/');

try {
$this->response = $this->client->request(
'PROPPATCH',
$davUrl,
[
'headers' => [
'Content-Type' => 'application/xml; charset=UTF-8',
],
'body' => $body,
'auth' => [
$user,
$password,
],
]
);
} catch (\GuzzleHttp\Exception\ClientException $e) {
$this->response = $e->getResponse();
}
}
}
30 changes: 30 additions & 0 deletions build/integration/features/caldav-delegation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
Feature: calendar delegation
Calendar delegation grants another user/principal control of a calendar account,
including all calendars the delegator can access.

@caldav-delegation
Scenario: admin grants user0 read access to her calendar account
Given user "admin" exists
And user "user0" exists
When "admin" updates property "{DAV:}group-member-set" to href "/remote.php/dav/principals/users/user0" of principal "users/admin/calendar-proxy-read" on the endpoint "/remote.php/dav/principals/"
Then The CalDAV response should be multi status
And The CalDAV response should contain an href "/remote.php/dav/principals/users/admin/calendar-proxy-read"
And The CalDAV response should contain a property "{DAV:}group-member-set"

@caldav-delegation
Scenario: admin grants write access to her calendar account
Given user "admin" exists
And user "user0" exists
When "admin" updates property "{DAV:}group-member-set" to href "/remote.php/dav/principals/users/user0" of principal "users/admin/calendar-proxy-write" on the endpoint "/remote.php/dav/principals/"
Then The CalDAV response should be multi status
And The CalDAV response should contain an href "/remote.php/dav/principals/users/admin/calendar-proxy-write"
And The CalDAV response should contain a property "{DAV:}group-member-set"

Scenario: Admin cannot grant User1 access to User0's calendar account
Given user "admin" exists
And user "user0" exists
And user "user1" exists
When "admin" updates property "{DAV:}group-member-set" to href "/remote.php/dav/principals/users/user1" of principal "users/user0/calendar-proxy-write" on the endpoint "/remote.php/dav/principals/"
Then The CalDAV HTTP status code should be "404"
2 changes: 1 addition & 1 deletion build/integration/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ else
fi
NC_DATADIR=$($OCC config:system:get datadirectory)

composer install
composer install --no-audit

# avoid port collision on jenkins - use $EXECUTOR_NUMBER
if [ -z "$EXECUTOR_NUMBER" ]; then
Expand Down
Loading