diff --git a/_config/extensions.yml b/_config/extensions.yml new file mode 100644 index 000000000..26f8ed4f2 --- /dev/null +++ b/_config/extensions.yml @@ -0,0 +1,9 @@ +--- +Name: coreextensions +--- +SilverStripe\Security\Member: + extensions: + - SilverStripe\Security\InheritedPermissionFlusher +SilverStripe\Security\Group: + extensions: + - SilverStripe\Security\InheritedPermissionFlusher \ No newline at end of file diff --git a/src/Core/Cache/CacheFlusher.php b/src/Core/Cache/CacheFlusher.php new file mode 100644 index 000000000..6d5788415 --- /dev/null +++ b/src/Core/Cache/CacheFlusher.php @@ -0,0 +1,16 @@ +removeFilterOn('Group_Members'); }); } + // Now set all children groups as a new foreign key - $groups = Group::get()->byIDs($this->collateFamilyIDs()); - $result = $result->forForeignID($groups->column('ID'))->where($filter); + $familyIDs = $this->collateFamilyIDs(); + if (!empty($familyIDs)) { + $groups = Group::get()->byIDs($familyIDs); + $groupIDs = $groups->column('ID'); + if (!empty($groupIDs)) { + $result = $result->forForeignID($groupIDs)->where($filter); + } + } return $result; } diff --git a/src/Security/InheritedPermissionFlusher.php b/src/Security/InheritedPermissionFlusher.php new file mode 100644 index 000000000..fd5c257d1 --- /dev/null +++ b/src/Security/InheritedPermissionFlusher.php @@ -0,0 +1,104 @@ +flushCache(); + } + + /** + * @param DataObject $owner + */ + public function setOwner($owner) + { + if (!$owner instanceof Member && !$owner instanceof Group) { + throw new InvalidArgumentException(sprintf( + '%s can only be applied to %s or %s', + __CLASS__, + Member::class, + Group::class + )); + } + + parent::setOwner($owner); + } + + /** + * @param CacheFlusher[] + */ + public function setServices($services) + { + foreach ($services as $service) { + if (!$service instanceof CacheFlusher) { + throw new InvalidArgumentException(sprintf( + '%s.services must contain only %s instances. %s provided.', + __CLASS__, + CacheFlusher::class, + get_class($service) + )); + } + } + + $this->services = $services; + } + + /** + * @return CacheFlusher[] + */ + public function getServices() + { + return $this->services; + } + + /** + * Flushes all registered CacheFlusher services + */ + public function flushCache() + { + $ids = $this->getMemberIDList(); + foreach ($this->services as $service) { + $service->flushCache($ids); + } + } + + /** + * Get a list of member IDs that need their permissions flushed + * + * @return array|null + */ + protected function getMemberIDList() + { + if (!$this->owner) { + return null; + } + + if (!$this->owner->exists()) { + return null; + } + + if ($this->owner instanceof Group) { + return $this->owner->Members()->exists() + ? $this->owner->Members()->column('ID') + : null; + } + + return [$this->owner->ID]; + } +} \ No newline at end of file diff --git a/tests/php/Security/InheritedPermissionsFlusherTest.php b/tests/php/Security/InheritedPermissionsFlusherTest.php new file mode 100644 index 000000000..e940405b3 --- /dev/null +++ b/tests/php/Security/InheritedPermissionsFlusherTest.php @@ -0,0 +1,100 @@ +load([ + CacheInterface::class . '.TestFlusherCache' => [ + 'factory' => CacheFactory::class, + 'constructor' => ['namespace' => 'TestFlusherCache'] + ] + ]); + } + + public function testMemberFlushesPermissions() + { + $cache = Injector::inst()->create(CacheInterface::class . '.TestFlusherCache'); + $flusher = new TestCacheFlusher($cache); + $extension = new InheritedPermissionFlusher(); + $extension->setServices([$flusher]); + Injector::inst()->registerService($extension, InheritedPermissionFlusher::class); + $editor = $this->objFromFixture(Member::class, 'editor'); + $admin = $this->objFromFixture(Member::class, 'admin'); + $editorKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $editor->ID); + $adminKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $admin->ID); + $cache->set($editorKey, 'uncle'); + $cache->set($adminKey, 'cheese'); + $editor->flushCache(); + + $this->assertNull($cache->get($editorKey)); + $this->assertEquals('cheese', $cache->get($adminKey)); + + $admin->flushCache(); + $this->assertNull($cache->get($editorKey)); + $this->assertNull($cache->get($adminKey)); + } + + public function testGroupFlushesPermissions() + { + $cache = Injector::inst()->create(CacheInterface::class . '.TestFlusherCache'); + $flusher = new TestCacheFlusher($cache); + $extension = new InheritedPermissionFlusher(); + $extension->setServices([$flusher]); + Injector::inst()->registerService($extension, InheritedPermissionFlusher::class); + $editors = $this->objFromFixture(Group::class, 'editors'); + $admins = $this->objFromFixture(Group::class, 'admins'); + + // Populate the cache for all members in each group + foreach ($editors->Members() as $editor) { + $editorKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $editor->ID); + $cache->set($editorKey, 'uncle'); + } + foreach ($admins->Members() as $admin) { + $adminKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $admin->ID); + $cache->set($adminKey, 'cheese'); + } + + // Clear the cache for all members in the editors group + $editors->flushCache(); + + foreach ($editors->Members() as $editor) { + $editorKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $editor->ID); + $this->assertNull($cache->get($editorKey)); + } + // Admins group should be unaffected + foreach ($admins->Members() as $admin) { + $adminKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $admin->ID); + $this->assertEquals('cheese', $cache->get($adminKey)); + } + + + $admins->flushCache(); + // Admins now affected + foreach ($admins->Members() as $admin) { + $adminKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $admin->ID); + $this->assertNull($cache->get($adminKey)); + } + foreach ($editors->Members() as $editor) { + $editorKey = $flusher->generateCacheKey(TestCacheFlusher::$categories[0], $editor->ID); + $this->assertNull($cache->get($editorKey)); + } + } +} \ No newline at end of file diff --git a/tests/php/Security/InheritedPermissionsFlusherTest.yml b/tests/php/Security/InheritedPermissionsFlusherTest.yml new file mode 100644 index 000000000..063d90aa6 --- /dev/null +++ b/tests/php/Security/InheritedPermissionsFlusherTest.yml @@ -0,0 +1,35 @@ +SilverStripe\Security\Group: + editors: + Title: Editors + admins: + Title: Administrators + allsections: + Title: All Section Editors + securityadmins: + Title: Security Admins + +SilverStripe\Security\Permission: + admins: + Code: ADMIN + Group: =>SilverStripe\Security\Group.admins + editors: + Code: CMS_ACCESS_CMSMain + Group: =>SilverStripe\Security\Group.editors + testpermission: + Code: TEST_NODE_ACCESS + Group: =>SilverStripe\Security\Group.editors + + +SilverStripe\Security\Member: + editor: + FirstName: Test + Surname: Editor + Groups: =>SilverStripe\Security\Group.editors + admin: + FirstName: Test + Surname: Administrator + Groups: =>SilverStripe\Security\Group.admins + allsections: + Groups: =>SilverStripe\Security\Group.allsections + securityadmin: + Groups: =>SilverStripe\Security\Group.securityadmins \ No newline at end of file diff --git a/tests/php/Security/InheritedPermissionsFlusherTest/TestCacheFlusher.php b/tests/php/Security/InheritedPermissionsFlusherTest/TestCacheFlusher.php new file mode 100644 index 000000000..b6a3c17f6 --- /dev/null +++ b/tests/php/Security/InheritedPermissionsFlusherTest/TestCacheFlusher.php @@ -0,0 +1,67 @@ +cache = $cache; + } + + /** + * Clear the cache for this instance only + * @param array $ids A list of member IDs + */ + public function flushCache($ids = null) + { + if (!$this->cache) { + return; + } + + // Hard flush, e.g. flush=1 + if (!$ids) { + $this->cache->clear(); + } + + if ($ids && is_array($ids)) { + foreach (self::$categories as $category) { + foreach ($ids as $memberID) { + $key = $this->generateCacheKey($category, $memberID); + $this->cache->delete($key); + } + } + } + } + + /** + * @param $category + * @param $memberID + * @return string + */ + public function generateCacheKey($category, $memberID) + { + return "{$category}__{$memberID}"; + } +} \ No newline at end of file