1<?php 2 3declare(strict_types=1); 4 5namespace Sabre\DAVACL; 6 7use Sabre\DAV; 8use Sabre\DAV\Exception\BadRequest; 9use Sabre\DAV\Exception\Forbidden; 10use Sabre\DAV\Exception\NotAuthenticated; 11use Sabre\DAV\Exception\NotFound; 12use Sabre\DAV\INode; 13use Sabre\DAV\Xml\Property\Href; 14use Sabre\DAVACL\Exception\NeedPrivileges; 15use Sabre\HTTP\RequestInterface; 16use Sabre\HTTP\ResponseInterface; 17use Sabre\Uri; 18 19/** 20 * SabreDAV ACL Plugin. 21 * 22 * This plugin provides functionality to enforce ACL permissions. 23 * ACL is defined in RFC3744. 24 * 25 * In addition it also provides support for the {DAV:}current-user-principal 26 * property, defined in RFC5397 and the {DAV:}expand-property report, as 27 * defined in RFC3253. 28 * 29 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 30 * @author Evert Pot (http://evertpot.com/) 31 * @license http://sabre.io/license/ Modified BSD License 32 */ 33class Plugin extends DAV\ServerPlugin 34{ 35 /** 36 * Recursion constants. 37 * 38 * This only checks the base node 39 */ 40 const R_PARENT = 1; 41 42 /** 43 * Recursion constants. 44 * 45 * This checks every node in the tree 46 */ 47 const R_RECURSIVE = 2; 48 49 /** 50 * Recursion constants. 51 * 52 * This checks every parentnode in the tree, but not leaf-nodes. 53 */ 54 const R_RECURSIVEPARENTS = 3; 55 56 /** 57 * Reference to server object. 58 * 59 * @var DAV\Server 60 */ 61 protected $server; 62 63 /** 64 * List of urls containing principal collections. 65 * Modify this if your principals are located elsewhere. 66 * 67 * @var array 68 */ 69 public $principalCollectionSet = [ 70 'principals', 71 ]; 72 73 /** 74 * By default nodes that are inaccessible by the user, can still be seen 75 * in directory listings (PROPFIND on parent with Depth: 1). 76 * 77 * In certain cases it's desirable to hide inaccessible nodes. Setting this 78 * to true will cause these nodes to be hidden from directory listings. 79 * 80 * @var bool 81 */ 82 public $hideNodesFromListings = false; 83 84 /** 85 * This list of properties are the properties a client can search on using 86 * the {DAV:}principal-property-search report. 87 * 88 * The keys are the property names, values are descriptions. 89 * 90 * @var array 91 */ 92 public $principalSearchPropertySet = [ 93 '{DAV:}displayname' => 'Display name', 94 '{http://sabredav.org/ns}email-address' => 'Email address', 95 ]; 96 97 /** 98 * Any principal uri's added here, will automatically be added to the list 99 * of ACL's. They will effectively receive {DAV:}all privileges, as a 100 * protected privilege. 101 * 102 * @var array 103 */ 104 public $adminPrincipals = []; 105 106 /** 107 * The ACL plugin allows privileges to be assigned to users that are not 108 * logged in. To facilitate that, it modifies the auth plugin's behavior 109 * to only require login when a privileged operation was denied. 110 * 111 * Unauthenticated access can be considered a security concern, so it's 112 * possible to turn this feature off to harden the server's security. 113 * 114 * @var bool 115 */ 116 public $allowUnauthenticatedAccess = true; 117 118 /** 119 * Returns a list of features added by this plugin. 120 * 121 * This list is used in the response of a HTTP OPTIONS request. 122 * 123 * @return array 124 */ 125 public function getFeatures() 126 { 127 return ['access-control', 'calendarserver-principal-property-search']; 128 } 129 130 /** 131 * Returns a list of available methods for a given url. 132 * 133 * @param string $uri 134 * 135 * @return array 136 */ 137 public function getMethods($uri) 138 { 139 return ['ACL']; 140 } 141 142 /** 143 * Returns a plugin name. 144 * 145 * Using this name other plugins will be able to access other plugins 146 * using Sabre\DAV\Server::getPlugin 147 * 148 * @return string 149 */ 150 public function getPluginName() 151 { 152 return 'acl'; 153 } 154 155 /** 156 * Returns a list of reports this plugin supports. 157 * 158 * This will be used in the {DAV:}supported-report-set property. 159 * Note that you still need to subscribe to the 'report' event to actually 160 * implement them 161 * 162 * @param string $uri 163 * 164 * @return array 165 */ 166 public function getSupportedReportSet($uri) 167 { 168 return [ 169 '{DAV:}expand-property', 170 '{DAV:}principal-match', 171 '{DAV:}principal-property-search', 172 '{DAV:}principal-search-property-set', 173 ]; 174 } 175 176 /** 177 * Checks if the current user has the specified privilege(s). 178 * 179 * You can specify a single privilege, or a list of privileges. 180 * This method will throw an exception if the privilege is not available 181 * and return true otherwise. 182 * 183 * @param string $uri 184 * @param array|string $privileges 185 * @param int $recursion 186 * @param bool $throwExceptions if set to false, this method won't throw exceptions 187 * 188 * @throws NeedPrivileges 189 * @throws NotAuthenticated 190 * 191 * @return bool 192 */ 193 public function checkPrivileges($uri, $privileges, $recursion = self::R_PARENT, $throwExceptions = true) 194 { 195 if (!is_array($privileges)) { 196 $privileges = [$privileges]; 197 } 198 199 $acl = $this->getCurrentUserPrivilegeSet($uri); 200 201 $failed = []; 202 foreach ($privileges as $priv) { 203 if (!in_array($priv, $acl)) { 204 $failed[] = $priv; 205 } 206 } 207 208 if ($failed) { 209 if ($this->allowUnauthenticatedAccess && is_null($this->getCurrentUserPrincipal())) { 210 // We are not authenticated. Kicking in the Auth plugin. 211 $authPlugin = $this->server->getPlugin('auth'); 212 $reasons = $authPlugin->getLoginFailedReasons(); 213 $authPlugin->challenge( 214 $this->server->httpRequest, 215 $this->server->httpResponse 216 ); 217 throw new NotAuthenticated(implode(', ', $reasons).'. Login was needed for privilege: '.implode(', ', $failed).' on '.$uri); 218 } 219 if ($throwExceptions) { 220 throw new NeedPrivileges($uri, $failed); 221 } else { 222 return false; 223 } 224 } 225 226 return true; 227 } 228 229 /** 230 * Returns the standard users' principal. 231 * 232 * This is one authoritative principal url for the current user. 233 * This method will return null if the user wasn't logged in. 234 * 235 * @return string|null 236 */ 237 public function getCurrentUserPrincipal() 238 { 239 /** @var $authPlugin \Sabre\DAV\Auth\Plugin */ 240 $authPlugin = $this->server->getPlugin('auth'); 241 if (!$authPlugin) { 242 return null; 243 } 244 245 return $authPlugin->getCurrentPrincipal(); 246 } 247 248 /** 249 * Returns a list of principals that's associated to the current 250 * user, either directly or through group membership. 251 * 252 * @return array 253 */ 254 public function getCurrentUserPrincipals() 255 { 256 $currentUser = $this->getCurrentUserPrincipal(); 257 258 if (is_null($currentUser)) { 259 return []; 260 } 261 262 return array_merge( 263 [$currentUser], 264 $this->getPrincipalMembership($currentUser) 265 ); 266 } 267 268 /** 269 * Sets the default ACL rules. 270 * 271 * These rules are used for all nodes that don't implement the IACL interface. 272 */ 273 public function setDefaultAcl(array $acl) 274 { 275 $this->defaultAcl = $acl; 276 } 277 278 /** 279 * Returns the default ACL rules. 280 * 281 * These rules are used for all nodes that don't implement the IACL interface. 282 * 283 * @return array 284 */ 285 public function getDefaultAcl() 286 { 287 return $this->defaultAcl; 288 } 289 290 /** 291 * The default ACL rules. 292 * 293 * These rules are used for nodes that don't implement IACL. These default 294 * set of rules allow anyone to do anything, as long as they are 295 * authenticated. 296 * 297 * @var array 298 */ 299 protected $defaultAcl = [ 300 [ 301 'principal' => '{DAV:}authenticated', 302 'protected' => true, 303 'privilege' => '{DAV:}all', 304 ], 305 ]; 306 307 /** 308 * This array holds a cache for all the principals that are associated with 309 * a single principal. 310 * 311 * @var array 312 */ 313 protected $principalMembershipCache = []; 314 315 /** 316 * Returns all the principal groups the specified principal is a member of. 317 * 318 * @param string $mainPrincipal 319 * 320 * @return array 321 */ 322 public function getPrincipalMembership($mainPrincipal) 323 { 324 // First check our cache 325 if (isset($this->principalMembershipCache[$mainPrincipal])) { 326 return $this->principalMembershipCache[$mainPrincipal]; 327 } 328 329 $check = [$mainPrincipal]; 330 $principals = []; 331 332 while (count($check)) { 333 $principal = array_shift($check); 334 335 $node = $this->server->tree->getNodeForPath($principal); 336 if ($node instanceof IPrincipal) { 337 foreach ($node->getGroupMembership() as $groupMember) { 338 if (!in_array($groupMember, $principals)) { 339 $check[] = $groupMember; 340 $principals[] = $groupMember; 341 } 342 } 343 } 344 } 345 346 // Store the result in the cache 347 $this->principalMembershipCache[$mainPrincipal] = $principals; 348 349 return $principals; 350 } 351 352 /** 353 * Find out of a principal equals another principal. 354 * 355 * This is a quick way to find out whether a principal URI is part of a 356 * group, or any subgroups. 357 * 358 * The first argument is the principal URI you want to check against. For 359 * example the principal group, and the second argument is the principal of 360 * which you want to find out of it is the same as the first principal, or 361 * in a member of the first principal's group or subgroups. 362 * 363 * So the arguments are not interchangeable. If principal A is in group B, 364 * passing 'B', 'A' will yield true, but 'A', 'B' is false. 365 * 366 * If the second argument is not passed, we will use the current user 367 * principal. 368 * 369 * @param string $checkPrincipal 370 * @param string $currentPrincipal 371 * 372 * @return bool 373 */ 374 public function principalMatchesPrincipal($checkPrincipal, $currentPrincipal = null) 375 { 376 if (is_null($currentPrincipal)) { 377 $currentPrincipal = $this->getCurrentUserPrincipal(); 378 } 379 if ($currentPrincipal === $checkPrincipal) { 380 return true; 381 } 382 if (is_null($currentPrincipal)) { 383 return false; 384 } 385 386 return in_array( 387 $checkPrincipal, 388 $this->getPrincipalMembership($currentPrincipal) 389 ); 390 } 391 392 /** 393 * Returns a tree of supported privileges for a resource. 394 * 395 * The returned array structure should be in this form: 396 * 397 * [ 398 * [ 399 * 'privilege' => '{DAV:}read', 400 * 'abstract' => false, 401 * 'aggregates' => [] 402 * ] 403 * ] 404 * 405 * Privileges can be nested using "aggregates". Doing so means that 406 * if you assign someone the aggregating privilege, all the 407 * sub-privileges will automatically be granted. 408 * 409 * Marking a privilege as abstract means that the privilege cannot be 410 * directly assigned, but must be assigned via the parent privilege. 411 * 412 * So a more complex version might look like this: 413 * 414 * [ 415 * [ 416 * 'privilege' => '{DAV:}read', 417 * 'abstract' => false, 418 * 'aggregates' => [ 419 * [ 420 * 'privilege' => '{DAV:}read-acl', 421 * 'abstract' => false, 422 * 'aggregates' => [], 423 * ] 424 * ] 425 * ] 426 * ] 427 * 428 * @param string|INode $node 429 * 430 * @return array 431 */ 432 public function getSupportedPrivilegeSet($node) 433 { 434 if (is_string($node)) { 435 $node = $this->server->tree->getNodeForPath($node); 436 } 437 438 $supportedPrivileges = null; 439 if ($node instanceof IACL) { 440 $supportedPrivileges = $node->getSupportedPrivilegeSet(); 441 } 442 443 if (is_null($supportedPrivileges)) { 444 // Default 445 $supportedPrivileges = [ 446 '{DAV:}read' => [ 447 'abstract' => false, 448 'aggregates' => [ 449 '{DAV:}read-acl' => [ 450 'abstract' => false, 451 'aggregates' => [], 452 ], 453 '{DAV:}read-current-user-privilege-set' => [ 454 'abstract' => false, 455 'aggregates' => [], 456 ], 457 ], 458 ], 459 '{DAV:}write' => [ 460 'abstract' => false, 461 'aggregates' => [ 462 '{DAV:}write-properties' => [ 463 'abstract' => false, 464 'aggregates' => [], 465 ], 466 '{DAV:}write-content' => [ 467 'abstract' => false, 468 'aggregates' => [], 469 ], 470 '{DAV:}unlock' => [ 471 'abstract' => false, 472 'aggregates' => [], 473 ], 474 ], 475 ], 476 ]; 477 if ($node instanceof DAV\ICollection) { 478 $supportedPrivileges['{DAV:}write']['aggregates']['{DAV:}bind'] = [ 479 'abstract' => false, 480 'aggregates' => [], 481 ]; 482 $supportedPrivileges['{DAV:}write']['aggregates']['{DAV:}unbind'] = [ 483 'abstract' => false, 484 'aggregates' => [], 485 ]; 486 } 487 if ($node instanceof IACL) { 488 $supportedPrivileges['{DAV:}write']['aggregates']['{DAV:}write-acl'] = [ 489 'abstract' => false, 490 'aggregates' => [], 491 ]; 492 } 493 } 494 495 $this->server->emit( 496 'getSupportedPrivilegeSet', 497 [$node, &$supportedPrivileges] 498 ); 499 500 return $supportedPrivileges; 501 } 502 503 /** 504 * Returns the supported privilege set as a flat list. 505 * 506 * This is much easier to parse. 507 * 508 * The returned list will be index by privilege name. 509 * The value is a struct containing the following properties: 510 * - aggregates 511 * - abstract 512 * - concrete 513 * 514 * @param string|INode $node 515 * 516 * @return array 517 */ 518 final public function getFlatPrivilegeSet($node) 519 { 520 $privs = [ 521 'abstract' => false, 522 'aggregates' => $this->getSupportedPrivilegeSet($node), 523 ]; 524 525 $fpsTraverse = null; 526 $fpsTraverse = function ($privName, $privInfo, $concrete, &$flat) use (&$fpsTraverse) { 527 $myPriv = [ 528 'privilege' => $privName, 529 'abstract' => isset($privInfo['abstract']) && $privInfo['abstract'], 530 'aggregates' => [], 531 'concrete' => isset($privInfo['abstract']) && $privInfo['abstract'] ? $concrete : $privName, 532 ]; 533 534 if (isset($privInfo['aggregates'])) { 535 foreach ($privInfo['aggregates'] as $subPrivName => $subPrivInfo) { 536 $myPriv['aggregates'][] = $subPrivName; 537 } 538 } 539 540 $flat[$privName] = $myPriv; 541 542 if (isset($privInfo['aggregates'])) { 543 foreach ($privInfo['aggregates'] as $subPrivName => $subPrivInfo) { 544 $fpsTraverse($subPrivName, $subPrivInfo, $myPriv['concrete'], $flat); 545 } 546 } 547 }; 548 549 $flat = []; 550 $fpsTraverse('{DAV:}all', $privs, null, $flat); 551 552 return $flat; 553 } 554 555 /** 556 * Returns the full ACL list. 557 * 558 * Either a uri or a INode may be passed. 559 * 560 * null will be returned if the node doesn't support ACLs. 561 * 562 * @param string|DAV\INode $node 563 * 564 * @return array 565 */ 566 public function getAcl($node) 567 { 568 if (is_string($node)) { 569 $node = $this->server->tree->getNodeForPath($node); 570 } 571 if (!$node instanceof IACL) { 572 return $this->getDefaultAcl(); 573 } 574 $acl = $node->getACL(); 575 foreach ($this->adminPrincipals as $adminPrincipal) { 576 $acl[] = [ 577 'principal' => $adminPrincipal, 578 'privilege' => '{DAV:}all', 579 'protected' => true, 580 ]; 581 } 582 583 return $acl; 584 } 585 586 /** 587 * Returns a list of privileges the current user has 588 * on a particular node. 589 * 590 * Either a uri or a DAV\INode may be passed. 591 * 592 * null will be returned if the node doesn't support ACLs. 593 * 594 * @param string|DAV\INode $node 595 * 596 * @return array 597 */ 598 public function getCurrentUserPrivilegeSet($node) 599 { 600 if (is_string($node)) { 601 $node = $this->server->tree->getNodeForPath($node); 602 } 603 604 $acl = $this->getACL($node); 605 606 $collected = []; 607 608 $isAuthenticated = null !== $this->getCurrentUserPrincipal(); 609 610 foreach ($acl as $ace) { 611 $principal = $ace['principal']; 612 613 switch ($principal) { 614 case '{DAV:}owner': 615 $owner = $node->getOwner(); 616 if ($owner && $this->principalMatchesPrincipal($owner)) { 617 $collected[] = $ace; 618 } 619 break; 620 621 // 'all' matches for every user 622 case '{DAV:}all': 623 $collected[] = $ace; 624 break; 625 626 case '{DAV:}authenticated': 627 // Authenticated users only 628 if ($isAuthenticated) { 629 $collected[] = $ace; 630 } 631 break; 632 633 case '{DAV:}unauthenticated': 634 // Unauthenticated users only 635 if (!$isAuthenticated) { 636 $collected[] = $ace; 637 } 638 break; 639 640 default: 641 if ($this->principalMatchesPrincipal($ace['principal'])) { 642 $collected[] = $ace; 643 } 644 break; 645 } 646 } 647 648 // Now we deduct all aggregated privileges. 649 $flat = $this->getFlatPrivilegeSet($node); 650 651 $collected2 = []; 652 while (count($collected)) { 653 $current = array_pop($collected); 654 $collected2[] = $current['privilege']; 655 656 if (!isset($flat[$current['privilege']])) { 657 // Ignoring privileges that are not in the supported-privileges list. 658 $this->server->getLogger()->debug('A node has the "'.$current['privilege'].'" in its ACL list, but this privilege was not reported in the supportedPrivilegeSet list. This will be ignored.'); 659 continue; 660 } 661 foreach ($flat[$current['privilege']]['aggregates'] as $subPriv) { 662 $collected2[] = $subPriv; 663 $collected[] = $flat[$subPriv]; 664 } 665 } 666 667 return array_values(array_unique($collected2)); 668 } 669 670 /** 671 * Returns a principal based on its uri. 672 * 673 * Returns null if the principal could not be found. 674 * 675 * @param string $uri 676 * 677 * @return string|null 678 */ 679 public function getPrincipalByUri($uri) 680 { 681 $result = null; 682 $collections = $this->principalCollectionSet; 683 foreach ($collections as $collection) { 684 try { 685 $principalCollection = $this->server->tree->getNodeForPath($collection); 686 } catch (NotFound $e) { 687 // Ignore and move on 688 continue; 689 } 690 691 if (!$principalCollection instanceof IPrincipalCollection) { 692 // Not a principal collection, we're simply going to ignore 693 // this. 694 continue; 695 } 696 697 $result = $principalCollection->findByUri($uri); 698 if ($result) { 699 return $result; 700 } 701 } 702 } 703 704 /** 705 * Principal property search. 706 * 707 * This method can search for principals matching certain values in 708 * properties. 709 * 710 * This method will return a list of properties for the matched properties. 711 * 712 * @param array $searchProperties The properties to search on. This is a 713 * key-value list. The keys are property 714 * names, and the values the strings to 715 * match them on. 716 * @param array $requestedProperties this is the list of properties to 717 * return for every match 718 * @param string $collectionUri the principal collection to search on. 719 * If this is ommitted, the standard 720 * principal collection-set will be used 721 * @param string $test "allof" to use AND to search the 722 * properties. 'anyof' for OR. 723 * 724 * @return array This method returns an array structure similar to 725 * Sabre\DAV\Server::getPropertiesForPath. Returned 726 * properties are index by a HTTP status code. 727 */ 728 public function principalSearch(array $searchProperties, array $requestedProperties, $collectionUri = null, $test = 'allof') 729 { 730 if (!is_null($collectionUri)) { 731 $uris = [$collectionUri]; 732 } else { 733 $uris = $this->principalCollectionSet; 734 } 735 736 $lookupResults = []; 737 foreach ($uris as $uri) { 738 $principalCollection = $this->server->tree->getNodeForPath($uri); 739 if (!$principalCollection instanceof IPrincipalCollection) { 740 // Not a principal collection, we're simply going to ignore 741 // this. 742 continue; 743 } 744 745 $results = $principalCollection->searchPrincipals($searchProperties, $test); 746 foreach ($results as $result) { 747 $lookupResults[] = rtrim($uri, '/').'/'.$result; 748 } 749 } 750 751 $matches = []; 752 753 foreach ($lookupResults as $lookupResult) { 754 list($matches[]) = $this->server->getPropertiesForPath($lookupResult, $requestedProperties, 0); 755 } 756 757 return $matches; 758 } 759 760 /** 761 * Sets up the plugin. 762 * 763 * This method is automatically called by the server class. 764 */ 765 public function initialize(DAV\Server $server) 766 { 767 if ($this->allowUnauthenticatedAccess) { 768 $authPlugin = $server->getPlugin('auth'); 769 if (!$authPlugin) { 770 throw new \Exception('The Auth plugin must be loaded before the ACL plugin if you want to allow unauthenticated access.'); 771 } 772 $authPlugin->autoRequireLogin = false; 773 } 774 775 $this->server = $server; 776 $server->on('propFind', [$this, 'propFind'], 20); 777 $server->on('beforeMethod:*', [$this, 'beforeMethod'], 20); 778 $server->on('beforeBind', [$this, 'beforeBind'], 20); 779 $server->on('beforeUnbind', [$this, 'beforeUnbind'], 20); 780 $server->on('propPatch', [$this, 'propPatch']); 781 $server->on('beforeUnlock', [$this, 'beforeUnlock'], 20); 782 $server->on('report', [$this, 'report']); 783 $server->on('method:ACL', [$this, 'httpAcl']); 784 $server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel']); 785 $server->on('getPrincipalByUri', function ($principal, &$uri) { 786 $uri = $this->getPrincipalByUri($principal); 787 788 // Break event chain 789 if ($uri) { 790 return false; 791 } 792 }); 793 794 array_push($server->protectedProperties, 795 '{DAV:}alternate-URI-set', 796 '{DAV:}principal-URL', 797 '{DAV:}group-membership', 798 '{DAV:}principal-collection-set', 799 '{DAV:}current-user-principal', 800 '{DAV:}supported-privilege-set', 801 '{DAV:}current-user-privilege-set', 802 '{DAV:}acl', 803 '{DAV:}acl-restrictions', 804 '{DAV:}inherited-acl-set', 805 '{DAV:}owner', 806 '{DAV:}group' 807 ); 808 809 // Automatically mapping nodes implementing IPrincipal to the 810 // {DAV:}principal resourcetype. 811 $server->resourceTypeMapping['Sabre\\DAVACL\\IPrincipal'] = '{DAV:}principal'; 812 813 // Mapping the group-member-set property to the HrefList property 814 // class. 815 $server->xml->elementMap['{DAV:}group-member-set'] = 'Sabre\\DAV\\Xml\\Property\\Href'; 816 $server->xml->elementMap['{DAV:}acl'] = 'Sabre\\DAVACL\\Xml\\Property\\Acl'; 817 $server->xml->elementMap['{DAV:}acl-principal-prop-set'] = 'Sabre\\DAVACL\\Xml\\Request\\AclPrincipalPropSetReport'; 818 $server->xml->elementMap['{DAV:}expand-property'] = 'Sabre\\DAVACL\\Xml\\Request\\ExpandPropertyReport'; 819 $server->xml->elementMap['{DAV:}principal-property-search'] = 'Sabre\\DAVACL\\Xml\\Request\\PrincipalPropertySearchReport'; 820 $server->xml->elementMap['{DAV:}principal-search-property-set'] = 'Sabre\\DAVACL\\Xml\\Request\\PrincipalSearchPropertySetReport'; 821 $server->xml->elementMap['{DAV:}principal-match'] = 'Sabre\\DAVACL\\Xml\\Request\\PrincipalMatchReport'; 822 } 823 824 /* {{{ Event handlers */ 825 826 /** 827 * Triggered before any method is handled. 828 */ 829 public function beforeMethod(RequestInterface $request, ResponseInterface $response) 830 { 831 $method = $request->getMethod(); 832 $path = $request->getPath(); 833 834 $exists = $this->server->tree->nodeExists($path); 835 836 // If the node doesn't exists, none of these checks apply 837 if (!$exists) { 838 return; 839 } 840 841 switch ($method) { 842 case 'GET': 843 case 'HEAD': 844 case 'OPTIONS': 845 // For these 3 we only need to know if the node is readable. 846 $this->checkPrivileges($path, '{DAV:}read'); 847 break; 848 849 case 'PUT': 850 case 'LOCK': 851 // This method requires the write-content priv if the node 852 // already exists, and bind on the parent if the node is being 853 // created. 854 // The bind privilege is handled in the beforeBind event. 855 $this->checkPrivileges($path, '{DAV:}write-content'); 856 break; 857 858 case 'UNLOCK': 859 // Unlock is always allowed at the moment. 860 break; 861 862 case 'PROPPATCH': 863 $this->checkPrivileges($path, '{DAV:}write-properties'); 864 break; 865 866 case 'ACL': 867 $this->checkPrivileges($path, '{DAV:}write-acl'); 868 break; 869 870 case 'COPY': 871 case 'MOVE': 872 // Copy requires read privileges on the entire source tree. 873 // If the target exists write-content normally needs to be 874 // checked, however, we're deleting the node beforehand and 875 // creating a new one after, so this is handled by the 876 // beforeUnbind event. 877 // 878 // The creation of the new node is handled by the beforeBind 879 // event. 880 // 881 // If MOVE is used beforeUnbind will also be used to check if 882 // the sourcenode can be deleted. 883 $this->checkPrivileges($path, '{DAV:}read', self::R_RECURSIVE); 884 break; 885 } 886 } 887 888 /** 889 * Triggered before a new node is created. 890 * 891 * This allows us to check permissions for any operation that creates a 892 * new node, such as PUT, MKCOL, MKCALENDAR, LOCK, COPY and MOVE. 893 * 894 * @param string $uri 895 */ 896 public function beforeBind($uri) 897 { 898 list($parentUri) = Uri\split($uri); 899 $this->checkPrivileges($parentUri, '{DAV:}bind'); 900 } 901 902 /** 903 * Triggered before a node is deleted. 904 * 905 * This allows us to check permissions for any operation that will delete 906 * an existing node. 907 * 908 * @param string $uri 909 */ 910 public function beforeUnbind($uri) 911 { 912 list($parentUri) = Uri\split($uri); 913 $this->checkPrivileges($parentUri, '{DAV:}unbind', self::R_RECURSIVEPARENTS); 914 } 915 916 /** 917 * Triggered before a node is unlocked. 918 * 919 * @param string $uri 920 * @TODO: not yet implemented 921 */ 922 public function beforeUnlock($uri, DAV\Locks\LockInfo $lock) 923 { 924 } 925 926 /** 927 * Triggered before properties are looked up in specific nodes. 928 * 929 * @TODO really should be broken into multiple methods, or even a class. 930 * 931 * @return bool 932 */ 933 public function propFind(DAV\PropFind $propFind, DAV\INode $node) 934 { 935 $path = $propFind->getPath(); 936 937 // Checking the read permission 938 if (!$this->checkPrivileges($path, '{DAV:}read', self::R_PARENT, false)) { 939 // User is not allowed to read properties 940 941 // Returning false causes the property-fetching system to pretend 942 // that the node does not exist, and will cause it to be hidden 943 // from listings such as PROPFIND or the browser plugin. 944 if ($this->hideNodesFromListings) { 945 return false; 946 } 947 948 // Otherwise we simply mark every property as 403. 949 foreach ($propFind->getRequestedProperties() as $requestedProperty) { 950 $propFind->set($requestedProperty, null, 403); 951 } 952 953 return; 954 } 955 956 /* Adding principal properties */ 957 if ($node instanceof IPrincipal) { 958 $propFind->handle('{DAV:}alternate-URI-set', function () use ($node) { 959 return new Href($node->getAlternateUriSet()); 960 }); 961 $propFind->handle('{DAV:}principal-URL', function () use ($node) { 962 return new Href($node->getPrincipalUrl().'/'); 963 }); 964 $propFind->handle('{DAV:}group-member-set', function () use ($node) { 965 $members = $node->getGroupMemberSet(); 966 foreach ($members as $k => $member) { 967 $members[$k] = rtrim($member, '/').'/'; 968 } 969 970 return new Href($members); 971 }); 972 $propFind->handle('{DAV:}group-membership', function () use ($node) { 973 $members = $node->getGroupMembership(); 974 foreach ($members as $k => $member) { 975 $members[$k] = rtrim($member, '/').'/'; 976 } 977 978 return new Href($members); 979 }); 980 $propFind->handle('{DAV:}displayname', [$node, 'getDisplayName']); 981 } 982 983 $propFind->handle('{DAV:}principal-collection-set', function () { 984 $val = $this->principalCollectionSet; 985 // Ensuring all collections end with a slash 986 foreach ($val as $k => $v) { 987 $val[$k] = $v.'/'; 988 } 989 990 return new Href($val); 991 }); 992 $propFind->handle('{DAV:}current-user-principal', function () { 993 if ($url = $this->getCurrentUserPrincipal()) { 994 return new Xml\Property\Principal(Xml\Property\Principal::HREF, $url.'/'); 995 } else { 996 return new Xml\Property\Principal(Xml\Property\Principal::UNAUTHENTICATED); 997 } 998 }); 999 $propFind->handle('{DAV:}supported-privilege-set', function () use ($node) { 1000 return new Xml\Property\SupportedPrivilegeSet($this->getSupportedPrivilegeSet($node)); 1001 }); 1002 $propFind->handle('{DAV:}current-user-privilege-set', function () use ($node, $propFind, $path) { 1003 if (!$this->checkPrivileges($path, '{DAV:}read-current-user-privilege-set', self::R_PARENT, false)) { 1004 $propFind->set('{DAV:}current-user-privilege-set', null, 403); 1005 } else { 1006 $val = $this->getCurrentUserPrivilegeSet($node); 1007 1008 return new Xml\Property\CurrentUserPrivilegeSet($val); 1009 } 1010 }); 1011 $propFind->handle('{DAV:}acl', function () use ($node, $propFind, $path) { 1012 /* The ACL property contains all the permissions */ 1013 if (!$this->checkPrivileges($path, '{DAV:}read-acl', self::R_PARENT, false)) { 1014 $propFind->set('{DAV:}acl', null, 403); 1015 } else { 1016 $acl = $this->getACL($node); 1017 1018 return new Xml\Property\Acl($this->getACL($node)); 1019 } 1020 }); 1021 $propFind->handle('{DAV:}acl-restrictions', function () { 1022 return new Xml\Property\AclRestrictions(); 1023 }); 1024 1025 /* Adding ACL properties */ 1026 if ($node instanceof IACL) { 1027 $propFind->handle('{DAV:}owner', function () use ($node) { 1028 return new Href($node->getOwner().'/'); 1029 }); 1030 } 1031 } 1032 1033 /** 1034 * This method intercepts PROPPATCH methods and make sure the 1035 * group-member-set is updated correctly. 1036 * 1037 * @param string $path 1038 */ 1039 public function propPatch($path, DAV\PropPatch $propPatch) 1040 { 1041 $propPatch->handle('{DAV:}group-member-set', function ($value) use ($path) { 1042 if (is_null($value)) { 1043 $memberSet = []; 1044 } elseif ($value instanceof Href) { 1045 $memberSet = array_map( 1046 [$this->server, 'calculateUri'], 1047 $value->getHrefs() 1048 ); 1049 } else { 1050 throw new DAV\Exception('The group-member-set property MUST be an instance of Sabre\DAV\Property\HrefList or null'); 1051 } 1052 $node = $this->server->tree->getNodeForPath($path); 1053 if (!($node instanceof IPrincipal)) { 1054 // Fail 1055 return false; 1056 } 1057 1058 $node->setGroupMemberSet($memberSet); 1059 // We must also clear our cache, just in case 1060 1061 $this->principalMembershipCache = []; 1062 1063 return true; 1064 }); 1065 } 1066 1067 /** 1068 * This method handles HTTP REPORT requests. 1069 * 1070 * @param string $reportName 1071 * @param mixed $report 1072 * @param mixed $path 1073 * 1074 * @return bool 1075 */ 1076 public function report($reportName, $report, $path) 1077 { 1078 switch ($reportName) { 1079 case '{DAV:}principal-property-search': 1080 $this->server->transactionType = 'report-principal-property-search'; 1081 $this->principalPropertySearchReport($path, $report); 1082 1083 return false; 1084 case '{DAV:}principal-search-property-set': 1085 $this->server->transactionType = 'report-principal-search-property-set'; 1086 $this->principalSearchPropertySetReport($path, $report); 1087 1088 return false; 1089 case '{DAV:}expand-property': 1090 $this->server->transactionType = 'report-expand-property'; 1091 $this->expandPropertyReport($path, $report); 1092 1093 return false; 1094 case '{DAV:}principal-match': 1095 $this->server->transactionType = 'report-principal-match'; 1096 $this->principalMatchReport($path, $report); 1097 1098 return false; 1099 case '{DAV:}acl-principal-prop-set': 1100 $this->server->transactionType = 'acl-principal-prop-set'; 1101 $this->aclPrincipalPropSetReport($path, $report); 1102 1103 return false; 1104 } 1105 } 1106 1107 /** 1108 * This method is responsible for handling the 'ACL' event. 1109 * 1110 * @return bool 1111 */ 1112 public function httpAcl(RequestInterface $request, ResponseInterface $response) 1113 { 1114 $path = $request->getPath(); 1115 $body = $request->getBodyAsString(); 1116 1117 if (!$body) { 1118 throw new DAV\Exception\BadRequest('XML body expected in ACL request'); 1119 } 1120 1121 $acl = $this->server->xml->expect('{DAV:}acl', $body); 1122 $newAcl = $acl->getPrivileges(); 1123 1124 // Normalizing urls 1125 foreach ($newAcl as $k => $newAce) { 1126 $newAcl[$k]['principal'] = $this->server->calculateUri($newAce['principal']); 1127 } 1128 $node = $this->server->tree->getNodeForPath($path); 1129 1130 if (!$node instanceof IACL) { 1131 throw new DAV\Exception\MethodNotAllowed('This node does not support the ACL method'); 1132 } 1133 1134 $oldAcl = $this->getACL($node); 1135 1136 $supportedPrivileges = $this->getFlatPrivilegeSet($node); 1137 1138 /* Checking if protected principals from the existing principal set are 1139 not overwritten. */ 1140 foreach ($oldAcl as $oldAce) { 1141 if (!isset($oldAce['protected']) || !$oldAce['protected']) { 1142 continue; 1143 } 1144 1145 $found = false; 1146 foreach ($newAcl as $newAce) { 1147 if ( 1148 $newAce['privilege'] === $oldAce['privilege'] && 1149 $newAce['principal'] === $oldAce['principal'] && 1150 $newAce['protected'] 1151 ) { 1152 $found = true; 1153 } 1154 } 1155 1156 if (!$found) { 1157 throw new Exception\AceConflict('This resource contained a protected {DAV:}ace, but this privilege did not occur in the ACL request'); 1158 } 1159 } 1160 1161 foreach ($newAcl as $newAce) { 1162 // Do we recognize the privilege 1163 if (!isset($supportedPrivileges[$newAce['privilege']])) { 1164 throw new Exception\NotSupportedPrivilege('The privilege you specified ('.$newAce['privilege'].') is not recognized by this server'); 1165 } 1166 1167 if ($supportedPrivileges[$newAce['privilege']]['abstract']) { 1168 throw new Exception\NoAbstract('The privilege you specified ('.$newAce['privilege'].') is an abstract privilege'); 1169 } 1170 1171 // Looking up the principal 1172 try { 1173 $principal = $this->server->tree->getNodeForPath($newAce['principal']); 1174 } catch (NotFound $e) { 1175 throw new Exception\NotRecognizedPrincipal('The specified principal ('.$newAce['principal'].') does not exist'); 1176 } 1177 if (!($principal instanceof IPrincipal)) { 1178 throw new Exception\NotRecognizedPrincipal('The specified uri ('.$newAce['principal'].') is not a principal'); 1179 } 1180 } 1181 $node->setACL($newAcl); 1182 1183 $response->setStatus(200); 1184 1185 // Breaking the event chain, because we handled this method. 1186 return false; 1187 } 1188 1189 /* }}} */ 1190 1191 /* Reports {{{ */ 1192 1193 /** 1194 * The principal-match report is defined in RFC3744, section 9.3. 1195 * 1196 * This report allows a client to figure out based on the current user, 1197 * or a principal URL, the principal URL and principal URLs of groups that 1198 * principal belongs to. 1199 * 1200 * @param string $path 1201 */ 1202 protected function principalMatchReport($path, Xml\Request\PrincipalMatchReport $report) 1203 { 1204 $depth = $this->server->getHTTPDepth(0); 1205 if (0 !== $depth) { 1206 throw new BadRequest('The principal-match report is only defined on Depth: 0'); 1207 } 1208 1209 $currentPrincipals = $this->getCurrentUserPrincipals(); 1210 1211 $result = []; 1212 1213 if (Xml\Request\PrincipalMatchReport::SELF === $report->type) { 1214 // Finding all principals under the request uri that match the 1215 // current principal. 1216 foreach ($currentPrincipals as $currentPrincipal) { 1217 if ($currentPrincipal === $path || 0 === strpos($currentPrincipal, $path.'/')) { 1218 $result[] = $currentPrincipal; 1219 } 1220 } 1221 } else { 1222 // We need to find all resources that have a property that matches 1223 // one of the current principals. 1224 $candidates = $this->server->getPropertiesForPath( 1225 $path, 1226 [$report->principalProperty], 1227 1 1228 ); 1229 1230 foreach ($candidates as $candidate) { 1231 if (!isset($candidate[200][$report->principalProperty])) { 1232 continue; 1233 } 1234 1235 $hrefs = $candidate[200][$report->principalProperty]; 1236 1237 if (!$hrefs instanceof Href) { 1238 continue; 1239 } 1240 1241 foreach ($hrefs->getHrefs() as $href) { 1242 if (in_array(trim($href, '/'), $currentPrincipals)) { 1243 $result[] = $candidate['href']; 1244 continue 2; 1245 } 1246 } 1247 } 1248 } 1249 1250 $responses = []; 1251 1252 foreach ($result as $item) { 1253 $properties = []; 1254 1255 if ($report->properties) { 1256 $foo = $this->server->getPropertiesForPath($item, $report->properties); 1257 $foo = $foo[0]; 1258 $item = $foo['href']; 1259 unset($foo['href']); 1260 $properties = $foo; 1261 } 1262 1263 $responses[] = new DAV\Xml\Element\Response( 1264 $item, 1265 $properties, 1266 '200' 1267 ); 1268 } 1269 1270 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 1271 $this->server->httpResponse->setStatus(207); 1272 $this->server->httpResponse->setBody( 1273 $this->server->xml->write( 1274 '{DAV:}multistatus', 1275 $responses, 1276 $this->server->getBaseUri() 1277 ) 1278 ); 1279 } 1280 1281 /** 1282 * The expand-property report is defined in RFC3253 section 3.8. 1283 * 1284 * This report is very similar to a standard PROPFIND. The difference is 1285 * that it has the additional ability to look at properties containing a 1286 * {DAV:}href element, follow that property and grab additional elements 1287 * there. 1288 * 1289 * Other rfc's, such as ACL rely on this report, so it made sense to put 1290 * it in this plugin. 1291 * 1292 * @param string $path 1293 * @param Xml\Request\ExpandPropertyReport $report 1294 */ 1295 protected function expandPropertyReport($path, $report) 1296 { 1297 $depth = $this->server->getHTTPDepth(0); 1298 1299 $result = $this->expandProperties($path, $report->properties, $depth); 1300 1301 $xml = $this->server->xml->write( 1302 '{DAV:}multistatus', 1303 new DAV\Xml\Response\MultiStatus($result), 1304 $this->server->getBaseUri() 1305 ); 1306 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 1307 $this->server->httpResponse->setStatus(207); 1308 $this->server->httpResponse->setBody($xml); 1309 } 1310 1311 /** 1312 * This method expands all the properties and returns 1313 * a list with property values. 1314 * 1315 * @param array $path 1316 * @param array $requestedProperties the list of required properties 1317 * @param int $depth 1318 * 1319 * @return array 1320 */ 1321 protected function expandProperties($path, array $requestedProperties, $depth) 1322 { 1323 $foundProperties = $this->server->getPropertiesForPath($path, array_keys($requestedProperties), $depth); 1324 1325 $result = []; 1326 1327 foreach ($foundProperties as $node) { 1328 foreach ($requestedProperties as $propertyName => $childRequestedProperties) { 1329 // We're only traversing if sub-properties were requested 1330 if (!is_array($childRequestedProperties) || 0 === count($childRequestedProperties)) { 1331 continue; 1332 } 1333 1334 // We only have to do the expansion if the property was found 1335 // and it contains an href element. 1336 if (!array_key_exists($propertyName, $node[200])) { 1337 continue; 1338 } 1339 1340 if (!$node[200][$propertyName] instanceof DAV\Xml\Property\Href) { 1341 continue; 1342 } 1343 1344 $childHrefs = $node[200][$propertyName]->getHrefs(); 1345 $childProps = []; 1346 1347 foreach ($childHrefs as $href) { 1348 // Gathering the result of the children 1349 $childProps[] = [ 1350 'name' => '{DAV:}response', 1351 'value' => $this->expandProperties($href, $childRequestedProperties, 0)[0], 1352 ]; 1353 } 1354 1355 // Replacing the property with its expanded form. 1356 $node[200][$propertyName] = $childProps; 1357 } 1358 $result[] = new DAV\Xml\Element\Response($node['href'], $node); 1359 } 1360 1361 return $result; 1362 } 1363 1364 /** 1365 * principalSearchPropertySetReport. 1366 * 1367 * This method responsible for handing the 1368 * {DAV:}principal-search-property-set report. This report returns a list 1369 * of properties the client may search on, using the 1370 * {DAV:}principal-property-search report. 1371 * 1372 * @param string $path 1373 * @param Xml\Request\PrincipalSearchPropertySetReport $report 1374 */ 1375 protected function principalSearchPropertySetReport($path, $report) 1376 { 1377 $httpDepth = $this->server->getHTTPDepth(0); 1378 if (0 !== $httpDepth) { 1379 throw new DAV\Exception\BadRequest('This report is only defined when Depth: 0'); 1380 } 1381 1382 $writer = $this->server->xml->getWriter(); 1383 $writer->openMemory(); 1384 $writer->startDocument(); 1385 1386 $writer->startElement('{DAV:}principal-search-property-set'); 1387 1388 foreach ($this->principalSearchPropertySet as $propertyName => $description) { 1389 $writer->startElement('{DAV:}principal-search-property'); 1390 $writer->startElement('{DAV:}prop'); 1391 1392 $writer->writeElement($propertyName); 1393 1394 $writer->endElement(); // prop 1395 1396 if ($description) { 1397 $writer->write([[ 1398 'name' => '{DAV:}description', 1399 'value' => $description, 1400 'attributes' => ['xml:lang' => 'en'], 1401 ]]); 1402 } 1403 1404 $writer->endElement(); // principal-search-property 1405 } 1406 1407 $writer->endElement(); // principal-search-property-set 1408 1409 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 1410 $this->server->httpResponse->setStatus(200); 1411 $this->server->httpResponse->setBody($writer->outputMemory()); 1412 } 1413 1414 /** 1415 * principalPropertySearchReport. 1416 * 1417 * This method is responsible for handing the 1418 * {DAV:}principal-property-search report. This report can be used for 1419 * clients to search for groups of principals, based on the value of one 1420 * or more properties. 1421 * 1422 * @param string $path 1423 */ 1424 protected function principalPropertySearchReport($path, Xml\Request\PrincipalPropertySearchReport $report) 1425 { 1426 if ($report->applyToPrincipalCollectionSet) { 1427 $path = null; 1428 } 1429 if (0 !== $this->server->getHttpDepth('0')) { 1430 throw new BadRequest('Depth must be 0'); 1431 } 1432 $result = $this->principalSearch( 1433 $report->searchProperties, 1434 $report->properties, 1435 $path, 1436 $report->test 1437 ); 1438 1439 $prefer = $this->server->getHTTPPrefer(); 1440 1441 $this->server->httpResponse->setStatus(207); 1442 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 1443 $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); 1444 $this->server->httpResponse->setBody($this->server->generateMultiStatus($result, 'minimal' === $prefer['return'])); 1445 } 1446 1447 /** 1448 * aclPrincipalPropSet REPORT. 1449 * 1450 * This method is responsible for handling the {DAV:}acl-principal-prop-set 1451 * REPORT, as defined in: 1452 * 1453 * https://tools.ietf.org/html/rfc3744#section-9.2 1454 * 1455 * This REPORT allows a user to quickly fetch information about all 1456 * principals specified in the access control list. Most commonly this 1457 * is used to for example generate a UI with ACL rules, allowing you 1458 * to show names for principals for every entry. 1459 * 1460 * @param string $path 1461 */ 1462 protected function aclPrincipalPropSetReport($path, Xml\Request\AclPrincipalPropSetReport $report) 1463 { 1464 if (0 !== $this->server->getHTTPDepth(0)) { 1465 throw new BadRequest('The {DAV:}acl-principal-prop-set REPORT only supports Depth 0'); 1466 } 1467 1468 // Fetching ACL rules for the given path. We're using the property 1469 // API and not the local getACL, because it will ensure that all 1470 // business rules and restrictions are applied. 1471 $acl = $this->server->getProperties($path, '{DAV:}acl'); 1472 1473 if (!$acl || !isset($acl['{DAV:}acl'])) { 1474 throw new Forbidden('Could not fetch ACL rules for this path'); 1475 } 1476 1477 $principals = []; 1478 foreach ($acl['{DAV:}acl']->getPrivileges() as $ace) { 1479 if ('{' === $ace['principal'][0]) { 1480 // It's not a principal, it's one of the special rules such as {DAV:}authenticated 1481 continue; 1482 } 1483 1484 $principals[] = $ace['principal']; 1485 } 1486 1487 $properties = $this->server->getPropertiesForMultiplePaths( 1488 $principals, 1489 $report->properties 1490 ); 1491 1492 $this->server->httpResponse->setStatus(207); 1493 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 1494 $this->server->httpResponse->setBody( 1495 $this->server->generateMultiStatus($properties) 1496 ); 1497 } 1498 1499 /* }}} */ 1500 1501 /** 1502 * This method is used to generate HTML output for the 1503 * DAV\Browser\Plugin. This allows us to generate an interface users 1504 * can use to create new calendars. 1505 * 1506 * @param string $output 1507 * 1508 * @return bool 1509 */ 1510 public function htmlActionsPanel(DAV\INode $node, &$output) 1511 { 1512 if (!$node instanceof PrincipalCollection) { 1513 return; 1514 } 1515 1516 $output .= '<tr><td colspan="2"><form method="post" action=""> 1517 <h3>Create new principal</h3> 1518 <input type="hidden" name="sabreAction" value="mkcol" /> 1519 <input type="hidden" name="resourceType" value="{DAV:}principal" /> 1520 <label>Name (uri):</label> <input type="text" name="name" /><br /> 1521 <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br /> 1522 <label>Email address:</label> <input type="text" name="{http://sabredav*DOT*org/ns}email-address" /><br /> 1523 <input type="submit" value="create" /> 1524 </form> 1525 </td></tr>'; 1526 1527 return false; 1528 } 1529 1530 /** 1531 * Returns a bunch of meta-data about the plugin. 1532 * 1533 * Providing this information is optional, and is mainly displayed by the 1534 * Browser plugin. 1535 * 1536 * The description key in the returned array may contain html and will not 1537 * be sanitized. 1538 * 1539 * @return array 1540 */ 1541 public function getPluginInfo() 1542 { 1543 return [ 1544 'name' => $this->getPluginName(), 1545 'description' => 'Adds support for WebDAV ACL (rfc3744)', 1546 'link' => 'http://sabre.io/dav/acl/', 1547 ]; 1548 } 1549} 1550