baseClass = $baseClass; $this->cacheService = $cache; return $this; } /** * Commits the cache */ public function __destruct() { // Ensure back-end cache is updated if (!empty($this->cachePermissions) && $this->cacheService) { foreach ($this->cachePermissions as $key => $permissions) { $this->cacheService->set($key, $permissions); } // Prevent double-destruct $this->cachePermissions = []; } } /** * Clear the cache for this instance only * * @param array $memberIDs A list of member IDs */ public function flushMemberCache($memberIDs = null) { if (!$this->cacheService) { return; } // Hard flush, e.g. flush=1 if (!$memberIDs) { $this->cacheService->clear(); } if ($memberIDs && is_array($memberIDs)) { foreach ([self::VIEW, self::EDIT, self::DELETE] as $type) { foreach ($memberIDs as $memberID) { $key = $this->generateCacheKey($type, $memberID); $this->cacheService->delete($key); } } } } /** * @param DefaultPermissionChecker $callback * @return $this */ public function setDefaultPermissions(DefaultPermissionChecker $callback) { $this->defaultPermissions = $callback; return $this; } /** * Global permissions required to edit * * @param array $permissions * @return $this */ public function setGlobalEditPermissions($permissions) { $this->globalEditPermissions = $permissions; return $this; } /** * @return array */ public function getGlobalEditPermissions() { return $this->globalEditPermissions; } /** * Get root permissions handler, or null if no handler * * @return DefaultPermissionChecker|null */ public function getDefaultPermissions() { return $this->defaultPermissions; } /** * Get base class * * @return string */ public function getBaseClass() { return $this->baseClass; } /** * Force pre-calculation of a list of permissions for optimisation * * @param string $permission * @param array $ids */ public function prePopulatePermissionCache($permission = 'edit', $ids = []) { switch ($permission) { case self::EDIT: $this->canEditMultiple($ids, Security::getCurrentUser(), false); break; case self::VIEW: $this->canViewMultiple($ids, Security::getCurrentUser(), false); break; case self::DELETE: $this->canDeleteMultiple($ids, Security::getCurrentUser(), false); break; default: throw new InvalidArgumentException("Invalid permission type $permission"); } } /** * This method is NOT a full replacement for the individual can*() methods, e.g. {@link canEdit()}. Rather than * checking (potentially slow) PHP logic, it relies on the database group associations, e.g. the "CanEditType" field * plus the "SiteTree_EditorGroups" many-many table. By batch checking multiple records, we can combine the queries * efficiently. * * Caches based on $typeField data. To invalidate the cache, use {@link SiteTree::reset()} or set the $useCached * property to FALSE. * * @param string $type Either edit, view, or create * @param array $ids Array of IDs * @param Member $member Member * @param array $globalPermission If the member doesn't have this permission code, don't bother iterating deeper * @param bool $useCached Enables use of cache. Cache will be populated even if this is false. * @return array A map of permissions, keys are ID numbers, and values are boolean permission checks * ID keys to boolean values */ protected function batchPermissionCheck( $type, $ids, Member $member = null, $globalPermission = [], $useCached = true ) { // Validate ids $ids = array_filter($ids ?? [], 'is_numeric'); if (empty($ids)) { return []; } // Default result: nothing editable $result = array_fill_keys($ids ?? [], false); // Validate member permission // Only VIEW allows anonymous (Anyone) permissions $memberID = $member ? (int)$member->ID : 0; if (!$memberID && $type !== self::VIEW) { return $result; } // Look in the cache for values $cacheKey = $this->generateCacheKey($type, $memberID); $cachePermissions = $this->getCachePermissions($cacheKey); if ($useCached && $cachePermissions) { $cachedValues = array_intersect_key($cachePermissions ?? [], $result); // If we can't find everything in the cache, then look up the remainder separately $uncachedIDs = array_keys(array_diff_key($result ?? [], $cachePermissions)); if ($uncachedIDs) { $uncachedValues = $this->batchPermissionCheck($type, $uncachedIDs, $member, $globalPermission, false); return $cachedValues + $uncachedValues; } return $cachedValues; } // If a member doesn't have a certain permission then they can't edit anything if ($globalPermission && !Permission::checkMember($member, $globalPermission)) { return $result; } // Get the groups that the given member belongs to $groupIDsSQLList = '0'; if ($memberID) { $groupIDs = $member->Groups()->column("ID"); $groupIDsSQLList = implode(", ", $groupIDs) ?: '0'; } // Check if record is versioned if ($this->isVersioned()) { // Check all records for each stage and merge $combinedStageResult = []; foreach ([Versioned::DRAFT, Versioned::LIVE] as $stage) { $stageRecords = Versioned::get_by_stage($this->getBaseClass(), $stage) ->byIDs($ids); // Exclude previously calculated records from later stage calculations if ($combinedStageResult) { $stageRecords = $stageRecords->exclude('ID', array_keys($combinedStageResult ?? [])); } $stageResult = $this->batchPermissionCheckForStage( $type, $globalPermission, $stageRecords, $groupIDsSQLList, $member ); // Note: Draft stage takes precedence over live, but only if draft exists $combinedStageResult = $combinedStageResult + $stageResult; } } else { // Unstaged result $stageRecords = DataObject::get($this->getBaseClass())->byIDs($ids); $combinedStageResult = $this->batchPermissionCheckForStage( $type, $globalPermission, $stageRecords, $groupIDsSQLList, $member ); } // Cache the results if (empty($this->cachePermissions[$cacheKey])) { $this->cachePermissions[$cacheKey] = []; } if ($combinedStageResult) { $this->cachePermissions[$cacheKey] = $combinedStageResult + $this->cachePermissions[$cacheKey]; } return $combinedStageResult; } /** * @param string $type * @param array $globalPermission List of global permissions * @param DataList $stageRecords List of records to check for this stage * @param string $groupIDsSQLList Group IDs this member belongs to * @param Member $member * @return array */ protected function batchPermissionCheckForStage( $type, $globalPermission, DataList $stageRecords, $groupIDsSQLList, Member $member = null ) { // Initialise all IDs to false $result = array_fill_keys($stageRecords->column('ID') ?? [], false); // Get the uninherited permissions $typeField = $this->getPermissionField($type); $baseTable = DataObject::getSchema()->baseDataTable($this->getBaseClass()); if ($member && $member->ID) { if (!Permission::checkMember($member, 'ADMIN')) { // Determine if this member matches any of the group or other rules $groupJoinTable = $this->getJoinTable($type); $uninheritedPermissions = $stageRecords ->where([ "(\"$typeField\" IN (?, ?) OR " . "(\"$typeField\" = ? AND \"$groupJoinTable\".\"{$baseTable}ID\" IS NOT NULL))" => [ self::ANYONE, self::LOGGED_IN_USERS, self::ONLY_THESE_USERS ] ]) ->leftJoin( $groupJoinTable, "\"$groupJoinTable\".\"{$baseTable}ID\" = \"{$baseTable}\".\"ID\" AND " . "\"$groupJoinTable\".\"GroupID\" IN ($groupIDsSQLList)" )->column('ID'); } else { $uninheritedPermissions = $stageRecords->column('ID'); } } else { // Only view pages with ViewType = Anyone if not logged in $uninheritedPermissions = $stageRecords ->filter($typeField, self::ANYONE) ->column('ID'); } if ($uninheritedPermissions) { // Set all the relevant items in $result to true $result = array_fill_keys($uninheritedPermissions ?? [], true) + $result; } // This looks for any of our subjects who has their permission set to "inherited" in the CMS. // We group these and run a batch permission check on all parents. This gives us the result // of whether the user has permission to edit this object. $groupedByParent = []; $potentiallyInherited = $stageRecords->filter($typeField, self::INHERIT) ->sort("\"{$baseTable}\".\"ID\"") ->dataQuery() ->query() ->setSelect([ "\"{$baseTable}\".\"ID\"", "\"{$baseTable}\".\"ParentID\"" ]) ->execute(); foreach ($potentiallyInherited as $item) { /** @var DataObject|Hierarchy $item */ if ($item['ParentID']) { if (!isset($groupedByParent[$item['ParentID']])) { $groupedByParent[$item['ParentID']] = []; } $groupedByParent[$item['ParentID']][] = $item['ID']; } else { // Fail over to default permission check for Inherit and ParentID = 0 $result[$item['ID']] = $this->checkDefaultPermissions($type, $member); } } // Copy permissions from parent to child if (!empty($groupedByParent)) { $actuallyInherited = $this->batchPermissionCheck( $type, array_keys($groupedByParent ?? []), $member, $globalPermission ); if ($actuallyInherited) { $parentIDs = array_keys(array_filter($actuallyInherited ?? [])); foreach ($parentIDs as $parentID) { // Set all the relevant items in $result to true $result = array_fill_keys($groupedByParent[$parentID] ?? [], true) + $result; } } } return $result; } /** * @param array $ids * @param Member|null $member * @param bool $useCached * @return array */ public function canEditMultiple($ids, Member $member = null, $useCached = true) { return $this->batchPermissionCheck( self::EDIT, $ids, $member, $this->getGlobalEditPermissions(), $useCached ); } /** * @param array $ids * @param Member|null $member * @param bool $useCached * @return array */ public function canViewMultiple($ids, Member $member = null, $useCached = true) { return $this->batchPermissionCheck(self::VIEW, $ids, $member, [], $useCached); } /** * @param array $ids * @param Member|null $member * @param bool $useCached * @return array */ public function canDeleteMultiple($ids, Member $member = null, $useCached = true) { // Validate ids $ids = array_filter($ids ?? [], 'is_numeric'); if (empty($ids)) { return []; } $result = array_fill_keys($ids ?? [], false); // Validate member permission if (!$member || !$member->ID) { return $result; } $deletable = []; // Look in the cache for values $cacheKey = "delete-{$member->ID}"; $cachePermissions = $this->getCachePermissions($cacheKey); if ($useCached && $cachePermissions) { $cachedValues = array_intersect_key($cachePermissions[$cacheKey] ?? [], $result); // If we can't find everything in the cache, then look up the remainder separately $uncachedIDs = array_keys(array_diff_key($result ?? [], $cachePermissions[$cacheKey])); if ($uncachedIDs) { $uncachedValues = $this->canDeleteMultiple($uncachedIDs, $member, false); return $cachedValues + $uncachedValues; } return $cachedValues; } // You can only delete pages that you can edit $editableIDs = array_keys(array_filter($this->canEditMultiple($ids, $member) ?? [])); if ($editableIDs) { // You can only delete pages whose children you can delete $childRecords = DataObject::get($this->baseClass) ->filter('ParentID', $editableIDs); // Find out the children that can be deleted $children = $childRecords->map("ID", "ParentID"); $childIDs = $children->keys(); if ($childIDs) { $deletableChildren = $this->canDeleteMultiple($childIDs, $member); // Get a list of all the parents that have no undeletable children $deletableParents = array_fill_keys($editableIDs ?? [], true); foreach ($deletableChildren as $id => $canDelete) { if (!$canDelete) { unset($deletableParents[$children[$id]]); } } // Use that to filter the list of deletable parents that have children $deletableParents = array_keys($deletableParents ?? []); // Also get the $ids that don't have children $parents = array_unique($children->values() ?? []); $deletableLeafNodes = array_diff($editableIDs ?? [], $parents); // Combine the two $deletable = array_merge($deletableParents, $deletableLeafNodes); } else { $deletable = $editableIDs; } } // Convert the array of deletable IDs into a map of the original IDs with true/false as the value return array_fill_keys($deletable ?? [], true) + array_fill_keys($ids ?? [], false); } /** * @param int $id * @param Member|null $member * @return bool|mixed */ public function canDelete($id, Member $member = null) { // No ID: Check default permission if (!$id) { return $this->checkDefaultPermissions(self::DELETE, $member); } // Regular canEdit logic is handled by canEditMultiple $results = $this->canDeleteMultiple( [$id], $member ); // Check if in result return isset($results[$id]) ? $results[$id] : false; } /** * @param int $id * @param Member|null $member * @return bool|mixed */ public function canEdit($id, Member $member = null) { // No ID: Check default permission if (!$id) { return $this->checkDefaultPermissions(self::EDIT, $member); } // Regular canEdit logic is handled by canEditMultiple $results = $this->canEditMultiple( [$id], $member ); // Check if in result return isset($results[$id]) ? $results[$id] : false; } /** * @param int $id * @param Member|null $member * @return bool|mixed */ public function canView($id, Member $member = null) { // No ID: Check default permission if (!$id) { return $this->checkDefaultPermissions(self::VIEW, $member); } // Regular canView logic is handled by canViewMultiple $results = $this->canViewMultiple( [$id], $member ); // Check if in result return isset($results[$id]) ? $results[$id] : false; } /** * Get field to check for permission type for the given check. * Defaults to those provided by {@see InheritedPermissionsExtension) * * @param string $type * @return string */ protected function getPermissionField($type) { switch ($type) { case self::DELETE: // Delete uses edit type - Drop through case self::EDIT: return 'CanEditType'; case self::VIEW: return 'CanViewType'; default: throw new InvalidArgumentException("Invalid argument type $type"); } } /** * Get join table for type * Defaults to those provided by {@see InheritedPermissionsExtension) * * @param string $type * @return string */ protected function getJoinTable($type) { switch ($type) { case self::DELETE: // Delete uses edit type - Drop through case self::EDIT: return $this->getEditorGroupsTable(); case self::VIEW: return $this->getViewerGroupsTable(); default: throw new InvalidArgumentException("Invalid argument type $type"); } } /** * Determine default permission for a givion check * * @param string $type Method to check * @param Member $member * @return bool */ protected function checkDefaultPermissions($type, Member $member = null) { $defaultPermissions = $this->getDefaultPermissions(); if (!$defaultPermissions) { return false; } switch ($type) { case self::VIEW: return $defaultPermissions->canView($member); case self::EDIT: return $defaultPermissions->canEdit($member); case self::DELETE: return $defaultPermissions->canDelete($member); default: return false; } } /** * Check if this model has versioning * * @return bool */ protected function isVersioned() { if (!class_exists(Versioned::class)) { return false; } /** @var Versioned|DataObject $singleton */ $singleton = DataObject::singleton($this->getBaseClass()); return $singleton->hasExtension(Versioned::class) && $singleton->hasStages(); } /** * @return $this */ public function clearCache() { $this->cachePermissions = []; return $this; } /** * Get table to use for editor groups relation * * @return string */ protected function getEditorGroupsTable() { $table = DataObject::getSchema()->tableName($this->baseClass); return "{$table}_EditorGroups"; } /** * Get table to use for viewer groups relation * * @return string */ protected function getViewerGroupsTable() { $table = DataObject::getSchema()->tableName($this->baseClass); return "{$table}_ViewerGroups"; } /** * Gets the permission from cache * * @param string $cacheKey * @return mixed */ protected function getCachePermissions($cacheKey) { // Check local cache if (isset($this->cachePermissions[$cacheKey])) { return $this->cachePermissions[$cacheKey]; } // Check persistent cache if ($this->cacheService) { $result = $this->cacheService->get($cacheKey); // Warm local cache if ($result) { $this->cachePermissions[$cacheKey] = $result; return $result; } } return null; } /** * Creates a cache key for a member and type * * @param string $type * @param int $memberID * @return string */ protected function generateCacheKey($type, $memberID) { $classKey = str_replace('\\', '-', $this->baseClass ?? ''); return "{$type}-{$classKey}-{$memberID}"; } }