Merge pull request #115 from mateusz/admin-access

Fix CMS Admin access issues
This commit is contained in:
Ingo Schommer 2013-10-22 16:49:24 +02:00
commit 7c100f90d2
10 changed files with 373 additions and 140 deletions

View File

@ -30,17 +30,4 @@ class SubsiteAdmin extends ModelAdmin {
return $form;
}
public function getResponseNegotiator() {
$negotiator = parent::getResponseNegotiator();
$self = $this;
// Register a new callback
$negotiator->setCallback('SubsiteList', function() use(&$self) {
return $self->SubsiteList();
});
return $negotiator;
}
public function SubsiteList() {
return $this->renderWith('SubsiteList');
}
}

View File

@ -0,0 +1,38 @@
<?php
/**
* Section-agnostic PJAX controller.
*/
class SubsiteXHRController extends LeftAndMain {
/**
* Relax the access permissions, so anyone who has access to any CMS subsite can access this controller.
*/
public function canView($member = null) {
if (parent::canView()) return true;
if (Subsite::all_accessible_sites()->count()>0) return true;
return false;
}
public function getResponseNegotiator() {
$negotiator = parent::getResponseNegotiator();
$self = $this;
// Register a new callback
$negotiator->setCallback('SubsiteList', function() use(&$self) {
return $self->SubsiteList();
});
return $negotiator;
}
/**
* Provide the list of available subsites as a cms-section-agnostic PJAX handler.
*/
public function SubsiteList() {
return $this->renderWith('SubsiteList');
}
}

View File

@ -1,7 +1,7 @@
<?php
/**
* Decorator designed to add subsites support to LeftAndMain
*
*
* @package subsites
*/
class LeftAndMainSubsites extends Extension {
@ -9,26 +9,9 @@ class LeftAndMainSubsites extends Extension {
private static $allowed_actions = array('CopyToSubsite');
function init() {
//Use the session variable for current subsite in the CMS only
Subsite::$use_session_subsiteid = true;
Requirements::css('subsites/css/LeftAndMain_Subsites.css');
Requirements::javascript('subsites/javascript/LeftAndMain_Subsites.js');
Requirements::javascript('subsites/javascript/VirtualPage_Subsites.js');
if(isset($_GET['SubsiteID'])) {
// Clear current page when subsite changes (or is set for the first time)
if(!Session::get('SubsiteID') || $_GET['SubsiteID'] != Session::get('SubsiteID')) {
Session::clear("{$this->owner->class}.currentPage");
}
// Update current subsite in session
Subsite::changeSubsite($_GET['SubsiteID']);
//Redirect to clear the current page
return $this->owner->redirect('admin/');
}
// Set subsite ID based on currently shown record
$req = $this->owner->getRequest();
@ -50,24 +33,61 @@ class LeftAndMainSubsites extends Extension {
function updatePageOptions(&$fields) {
$fields->push(new HiddenField('SubsiteID', 'SubsiteID', Subsite::currentSubsiteID()));
}
/*
* Returns a list of the subsites accessible to the current user
/**
* Find all subsites accessible for current user on this controller.
*
* @return ArrayList of {@link Subsite} instances.
*/
public function Subsites() {
// figure out what permission the controller needs
// Subsite::accessible_sites() expects something, so if there's no permission
// then fallback to using CMS_ACCESS_LeftAndMain.
$permission = 'CMS_ACCESS_' . $this->owner->class;
$available = Permission::get_codes(false);
if(!isset($available[$permission])) {
$permission = $this->owner->stat('required_permission_codes');
if(!$permission) {
$permission = 'CMS_ACCESS_LeftAndMain';
function sectionSites($includeMainSite = true, $mainSiteTitle = "Main site", $member = null) {
// Rationalise member arguments
if(!$member) $member = Member::currentUser();
if(!$member) return new ArrayList();
if(!is_object($member)) $member = DataObject::get_by_id('Member', $member);
// Collect permissions - honour the LeftAndMain::required_permission_codes, current model requires
// us to check if the user satisfies ALL permissions. Code partly copied from LeftAndMain::canView.
$codes = array();
$extraCodes = Config::inst()->get($this->owner->class, 'required_permission_codes');
if($extraCodes !== false) {
if($extraCodes) $codes = array_merge($codes, (array)$extraCodes);
else $codes[] = "CMS_ACCESS_{$this->owner->class}";
} else {
// Check overriden - all subsites accessible.
return Subsite::all_sites();
}
// Find subsites satisfying all permissions for the Member.
$codesPerSite = array();
$sitesArray = array();
foreach ($codes as $code) {
$sites = Subsite::accessible_sites($code, $includeMainSite, $mainSiteTitle, $member);
foreach ($sites as $site) {
// Build the structure for checking how many codes match.
$codesPerSite[$site->ID][$code] = true;
// Retain Subsite objects for later.
$sitesArray[$site->ID] = $site;
}
}
return Subsite::accessible_sites($permission);
// Find sites that satisfy all codes conjuncitvely.
$accessibleSites = new ArrayList();
foreach ($codesPerSite as $siteID => $siteCodes) {
if (count($siteCodes)==count($codes)) {
$accessibleSites->push($sitesArray[$siteID]);
}
}
return $accessibleSites;
}
/*
* Returns a list of the subsites accessible to the current user.
* It's enough for any section to be accessible for the section to be included.
*/
public function Subsites() {
return Subsite::all_accessible_sites();
}
/*
@ -101,38 +121,26 @@ class LeftAndMainSubsites extends Extension {
return $output;
}
/*
* Returns a subset of the main menu, filtered by admins that have
* a subsiteCMSShowInMenu method returning true
*
* @return ArrayList
*/
public function SubsiteMainMenu(){
public function alternateMenuDisplayCheck($controllerName) {
if(!class_exists($controllerName)){
return false;
}
// Check subsite support.
if(Subsite::currentSubsiteID() == 0){
return $this->owner->MainMenu();
}
// loop main menu items, add all items that have subsite support
$mainMenu = $this->owner->MainMenu();
$subsitesMenu = new ArrayList();
foreach($mainMenu as $menuItem){
$controllerName = $menuItem->MenuItem->controller;
if(class_exists($controllerName)){
$controller = singleton($controllerName);
if($controller->hasMethod('subsiteCMSShowInMenu') && $controller->subsiteCMSShowInMenu()){
$subsitesMenu->push($menuItem);
}
// Main site always supports everything.
return true;
} else {
$controller = singleton($controllerName);
if($controller->hasMethod('subsiteCMSShowInMenu') && $controller->subsiteCMSShowInMenu()){
return true;
}
if($menuItem->Code == 'Help'){
$subsitesMenu->push($menuItem);
}
}
return $subsitesMenu;
// It's not necessary to check access permissions here. Framework calls canView on the controller,
// which in turn uses the Permission API which is augmented by our GroupSubsites.
return false;
}
public function CanAddSubsites() {
@ -140,58 +148,88 @@ class LeftAndMainSubsites extends Extension {
}
/**
* Alternative security checker for LeftAndMain.
* If security isn't found, then it will switch to a subsite where we do have access.
* Do some pre-flight checks if a subsite switch is needed.
* We redirect the user to something accessible if the current section/subsite is forbidden.
*/
public function alternateAccessCheck() {
public function onBeforeInit() {
// We are accessing the CMS, so we need to let Subsites know we will be using the session.
Subsite::$use_session_subsiteid = true;
// Do not try to be smart for AJAX requests.
if ($this->owner->request->isAjax()) {
return;
}
// Catch forced subsite changes that need to cause CMS reloads.
if(isset($_GET['SubsiteID'])) {
// Clear current page when subsite changes (or is set for the first time)
if(!Session::get('SubsiteID') || $_GET['SubsiteID'] != Session::get('SubsiteID')) {
Session::clear("{$this->owner->class}.currentPage");
}
// Update current subsite in session
Subsite::changeSubsite($_GET['SubsiteID']);
//Redirect to clear the current page
return $this->owner->redirect('admin/');
}
$className = $this->owner->class;
// Switch to the subsite of the current page
// Transparently switch to the subsite of the current page.
if ($this->owner->class == 'CMSMain' && $currentPage = $this->owner->currentPage()) {
if (Subsite::currentSubsiteID() != $currentPage->SubsiteID) {
Subsite::changeSubsite($currentPage->SubsiteID);
}
}
// Switch to a subsite that this user can actually access.
$member = Member::currentUser();
if($member && Permission::checkMember($member, 'ADMIN')) return true; // admin can access all subsites
$sites = Subsite::accessible_sites("CMS_ACCESS_{$this->owner->class}", true)->map('ID', 'Title');
if(is_object($sites)) $sites = $sites->toArray();
if($sites && !isset($sites[Subsite::currentSubsiteID()])) {
$siteIDs = array_keys($sites);
Subsite::changeSubsite($siteIDs[0]);
return true;
// If we can view current URL there is nothing to do.
if ($this->owner->canView()) {
return;
}
// Switch to a different top-level menu item
// Admin can access everything, no point in checking.
$member = Member::currentUser();
if($member && Permission::checkMember($member, 'ADMIN')) return;
// Check if we have access to current section on the current subsite.
$accessibleSites = $this->owner->sectionSites($member);
if ($accessibleSites->count() && $accessibleSites->find('ID', Subsite::currentSubsiteID())) {
// Current section can be accessed on the current site, all good.
return;
}
// If the current section is not accessible, try at least to stick to the same subsite.
$menu = CMSMenu::get_menu_items();
foreach($menu as $candidate) {
if($candidate->controller != $this->owner->class) {
$sites = Subsite::accessible_sites("CMS_ACCESS_{$candidate->controller}", true)->map('ID', 'Title');
if(is_object($sites)) $sites = $sites->toArray();
if($sites && !isset($sites[Subsite::currentSubsiteID()])) {
$siteIDs = array_keys($sites);
Subsite::changeSubsite($siteIDs[0]);
$cClass = $candidate->controller;
$cObj = new $cClass();
$this->owner->redirect($cObj->Link());
return null;
if($candidate->controller && $candidate->controller!=$this->owner->class) {
$accessibleSites = singleton($candidate->controller)->sectionSites(true, 'Main site', $member);
if ($accessibleSites->count() && $accessibleSites->find('ID', Subsite::currentSubsiteID())) {
// Section is accessible, redirect there.
$this->owner->redirect(singleton($candidate->controller)->Link());
return;
}
}
}
// Finally, if no section is available, move to any other permitted subsite.
foreach($menu as $candidate) {
if($candidate->controller) {
$accessibleSites = singleton($candidate->controller)->sectionSites(true, 'Main site', $member);
if ($accessibleSites->count()) {
Subsite::changeSubsite($accessibleSites->First()->ID);
$this->owner->redirect(singleton($candidate->controller)->Link());
return;
}
}
}
// If all of those fail, you really don't have access to the CMS
return null;
}
function augmentNewSiteTreeItem(&$item) {
$item->SubsiteID = isset($_POST['SubsiteID']) ? $_POST['SubsiteID'] : Subsite::currentSubsiteID();
}
function onAfterSave($record) {
if($record->hasMethod('NormalRelated') && ($record->NormalRelated() || $record->ReverseRelated())) {
$this->owner->response->addHeader('X-Status', rawurlencode(_t('LeftAndMainSubsites.Saved', 'Saved, please update related pages.')));

View File

@ -310,10 +310,9 @@ JS;
if(isset($_GET['SubsiteID'])) {
$id = (int)$_GET['SubsiteID'];
}
else if (Subsite::$use_session_subsiteid) {
} else if (Subsite::$use_session_subsiteid) {
$id = Session::get('SubsiteID');
}
}
if($id === NULL) {
$id = self::getSubsiteIDForDomain();
@ -522,12 +521,64 @@ JS;
return $duplicate;
}
/**
* Return all subsites, regardless of permissions (augmented with main site).
*
* @return SS_List List of {@link Subsite} objects (DataList or ArrayList).
*/
public static function all_sites($includeMainSite = true, $mainSiteTitle = "Main site") {
$subsites = Subsite::get();
if($includeMainSite) {
$subsites = $subsites->toArray();
$mainSite = new Subsite();
$mainSite->Title = $mainSiteTitle;
array_unshift($subsites, $mainSite);
$subsites = ArrayList::create($subsites);
}
return $subsites;
}
/*
* Returns an ArrayList of the subsites accessible to the current user.
* It's enough for any section to be accessible for the site to be included.
*
* @return ArrayList of {@link Subsite} instances.
*/
public static function all_accessible_sites($includeMainSite = true, $mainSiteTitle = "Main site", $member = null) {
// Rationalise member arguments
if(!$member) $member = Member::currentUser();
if(!$member) return new ArrayList();
if(!is_object($member)) $member = DataObject::get_by_id('Member', $member);
$subsites = new ArrayList();
// Collect subsites for all sections.
$menu = CMSMenu::get_viewable_menu_items();
foreach($menu as $candidate) {
if ($candidate->controller) {
$accessibleSites = singleton($candidate->controller)->sectionSites(
$includeMainSite,
$mainSiteTitle,
$member
);
// Replace existing keys so no one site appears twice.
$subsites->merge($accessibleSites);
}
}
$subsites->removeDuplicates();
return $subsites;
}
/**
* Return the subsites that the current user can access.
* Look for one of the given permission codes on the site.
*
* Sites will only be included if they have a Title
* Return the subsites that the current user can access by given permission.
* Sites will only be included if they have a Title.
*
* @param $permCode array|string Either a single permission code or an array of permission codes.
* @param $includeMainSite If true, the main site will be included if appropriate.

View File

@ -58,7 +58,7 @@
*/
$('.cms-container .cms-menu-list li a').entwine({
onclick: function(e) {
$('.cms-container').subsiteFetchPjaxFragment('admin/subsites/', 'SubsiteList');
$('.cms-container').subsiteFetchPjaxFragment('SubsiteXHRController', 'SubsiteList');
this._super(e);
}
});
@ -68,7 +68,7 @@
*/
$('.cms-container .SubsiteAdmin .cms-edit-form fieldset.ss-gridfield').entwine({
onreload: function(e) {
$('.cms-container').subsiteFetchPjaxFragment('admin/subsites/', 'SubsiteList');
$('.cms-container').subsiteFetchPjaxFragment('SubsiteXHRController', 'SubsiteList');
this._super(e);
}
});
@ -81,7 +81,7 @@
*/
$('.cms-container .cms-content-fields .subsite-model').entwine({
onadd: function(e) {
$('.cms-container').subsiteFetchPjaxFragment('admin/subsites/', 'SubsiteList');
$('.cms-container').subsiteFetchPjaxFragment('SubsiteXHRController', 'SubsiteList');
this._super(e);
}
});

View File

@ -25,7 +25,7 @@
<div class="cms-panel-content center">
<ul class="cms-menu-list">
<% loop $SubsiteMainMenu %>
<% loop $MainMenu %>
<li class="$LinkingMode $FirstLast <% if $LinkingMode == 'link' %><% else %>opened<% end_if %>" id="Menu-$Code" title="$Title.ATT">
<a href="$Link" <% if $Code == 'Help' %>target="_blank"<% end_if %>>
<span class="icon icon-16 icon-{$Code.LowerCase}">&nbsp;</span>

View File

@ -1,4 +1,5 @@
<?php
<?php
class LeftAndMainSubsitesTest extends FunctionalTest {
static $fixture_file = 'subsites/tests/SubsiteTest.yml';
@ -9,12 +10,34 @@ class LeftAndMainSubsitesTest extends FunctionalTest {
function objFromFixture($class, $id) {
Subsite::disable_subsite_filter(true);
$obj = parent::objFromFixture($class, $id);
Subsite::disable_subsite_filter(false);
Subsite::disable_subsite_filter(false);
return $obj;
}
function testAlternateAccessCheck() {
function testSectionSites() {
$member = $this->objFromFixture('Member', 'subsite1member');
$cmsmain = singleton('CMSMain');
$subsites = $cmsmain->sectionSites(true, "Main site", $member);
$this->assertDOSEquals(array(
array('Title' =>'Subsite1 Template')
), $subsites, 'Lists member-accessible sites for the accessible controller.');
$assetadmin = singleton('AssetAdmin');
$subsites = $assetadmin->sectionSites(true, "Main site", $member);
$this->assertDOSEquals(array(), $subsites, 'Does not list any sites for forbidden controller.');
$member = $this->objFromFixture('Member', 'editor');
$cmsmain = singleton('CMSMain');
$subsites = $cmsmain->sectionSites(true, "Main site", $member);
$this->assertDOSContains(array(
array('Title' =>'Main site')
), $subsites, 'Includes the main site for members who can access all sites.');
}
function testAccessChecksDontChangeCurrentSubsite() {
$admin = $this->objFromFixture("Member","admin");
$this->loginAs($admin);
$ids = array();
@ -28,11 +51,17 @@ class LeftAndMainSubsitesTest extends FunctionalTest {
$ids[] = $subsite3->ID;
$ids[] = 0;
// Enable session-based subsite tracking.
Subsite::$use_session_subsiteid = true;
foreach($ids as $id) {
Subsite::changeSubsite($id); //switch to main site (subsite ID zero)
Subsite::changeSubsite($id);
$this->assertEquals($id, Subsite::currentSubsiteID());
$left = new LeftAndMain();
$this->assertTrue($left->canView(), "Admin user can view subsites LeftAndMain with id = '$id'");
$this->assertEquals($id, Subsite::currentSubsiteID(), "The current subsite has not been changed in the process of checking permissions for admin user.");
$this->assertEquals($id, Subsite::currentSubsiteID(),
"The current subsite has not been changed in the process of checking permissions for admin user.");
}
}

View File

@ -41,6 +41,7 @@ class SiteTreeSubsitesTest extends BaseSubsiteTest {
function testBasicSanity() {
$this->assertTrue(singleton('SiteTree')->getSiteConfig() instanceof SiteConfig);
// The following assert is breaking in Translatable.
$this->assertTrue(singleton('SiteTree')->getCMSFields() instanceof FieldList);
$this->assertTrue(singleton('SubsitesVirtualPage')->getCMSFields() instanceof FieldList);
$this->assertTrue(is_array(singleton('SiteTreeSubsites')->extraStatics()));

View File

@ -6,6 +6,35 @@ class SubsiteAdminFunctionalTest extends FunctionalTest {
protected $autoFollowRedirection = false;
/**
* Helper: FunctionalTest is only able to follow redirection once, we want to go all the way.
*/
function getAndFollowAll($url) {
$response = $this->get($url);
while ($location = $response->getHeader('Location')) {
$response = $this->mainSession->followRedirection();
}
echo $response->getHeader('Location');
return $response;
}
/**
* Anonymous user cannot access anything.
*/
function testAnonymousIsForbiddenAdminAccess() {
$response = $this->getAndFollowAll('admin/pages/?SubsiteID=0');
$this->assertRegExp('#^Security/login.*#', $this->mainSession->lastUrl(), 'Admin is disallowed');
$subsite1 = $this->objFromFixture('Subsite', 'subsite1');
$response = $this->getAndFollowAll("admin/pages/?SubsiteID={$subsite1->ID}");
$this->assertRegExp('#^Security/login.*#', $this->mainSession->lastUrl(), 'Admin is disallowed');
$response = $this->getAndFollowAll('SubsiteXHRController');
$this->assertRegExp('#^Security/login.*#', $this->mainSession->lastUrl(),
'SubsiteXHRController is disallowed');
}
/**
* Admin should be able to access all subsites and the main site
*/
@ -13,14 +42,18 @@ class SubsiteAdminFunctionalTest extends FunctionalTest {
$member = $this->objFromFixture('Member', 'admin');
Session::set("loggedInAs", $member->ID);
$this->get('admin/pages?SubsiteID=0&ajax=1');
$this->get('admin');
$this->assertEquals(Subsite::currentSubsiteID(), '0', 'Can access main site');
$this->getAndFollowAll('admin/pages/?SubsiteID=0');
$this->assertEquals(Subsite::currentSubsiteID(), '0', 'Can access main site.');
$this->assertRegExp('#^admin/pages.*#', $this->mainSession->lastUrl(), 'Lands on the correct section');
$mainSubsite = $this->objFromFixture('Subsite', 'main');
$this->get("admin/pages?SubsiteID={$mainSubsite->ID}&ajax=1");
$this->get('admin');
$this->assertEquals(Subsite::currentSubsiteID(), $mainSubsite->ID, 'Can access the subsite');
$subsite1 = $this->objFromFixture('Subsite', 'subsite1');
$this->getAndFollowAll("admin/pages/?SubsiteID={$subsite1->ID}");
$this->assertEquals(Subsite::currentSubsiteID(), $subsite1->ID, 'Can access other subsite.');
$this->assertRegExp('#^admin/pages.*#', $this->mainSession->lastUrl(), 'Lands on the correct section');
$response = $this->getAndFollowAll('SubsiteXHRController');
$this->assertNotRegExp('#^Security/login.*#', $this->mainSession->lastUrl(),
'SubsiteXHRController is reachable');
}
/**
@ -30,14 +63,48 @@ class SubsiteAdminFunctionalTest extends FunctionalTest {
function testEditorCanAccessAllSubsites() {
$member = $this->objFromFixture('Member', 'editor');
Session::set("loggedInAs", $member->ID);
$this->get('admin/pages?SubsiteID=0&ajax=1');
$this->get('admin');
$this->assertEquals(Subsite::currentSubsiteID(), '0', 'Can access main site');
$mainSubsite = $this->objFromFixture('Subsite', 'main');
$this->get("admin/pages?SubsiteID={$mainSubsite->ID}&ajax=1");
$this->get('admin');
$this->assertEquals(Subsite::currentSubsiteID(), $mainSubsite->ID, 'Can access the subsite');
$this->getAndFollowAll('admin/pages/?SubsiteID=0');
$this->assertEquals(Subsite::currentSubsiteID(), '0', 'Can access main site.');
$this->assertRegExp('#^admin/pages.*#', $this->mainSession->lastUrl(), 'Lands on the correct section');
$subsite1 = $this->objFromFixture('Subsite', 'subsite1');
$this->getAndFollowAll("admin/pages/?SubsiteID={$subsite1->ID}");
$this->assertEquals(Subsite::currentSubsiteID(), $subsite1->ID, 'Can access other subsite.');
$this->assertRegExp('#^admin/pages.*#', $this->mainSession->lastUrl(), 'Lands on the correct section');
$response = $this->getAndFollowAll('SubsiteXHRController');
$this->assertNotRegExp('#^Security/login.*#', $this->mainSession->lastUrl(),
'SubsiteXHRController is reachable');
}
/**
* Test a member who only has access to one subsite (subsite1) and only some sections (pages and security).
*/
function testSubsiteAdmin() {
$member = $this->objFromFixture('Member', 'subsite1member');
Session::set("loggedInAs", $member->ID);
$subsite1 = $this->objFromFixture('Subsite', 'subsite1');
// Check allowed URL.
$this->getAndFollowAll("admin/pages/?SubsiteID={$subsite1->ID}");
$this->assertEquals(Subsite::currentSubsiteID(), $subsite1->ID, 'Can access own subsite.');
$this->assertRegExp('#^admin/pages.*#', $this->mainSession->lastUrl(), 'Can access permitted section.');
// Check forbidden section in allowed subsite.
$this->getAndFollowAll("admin/assets/?SubsiteID={$subsite1->ID}");
$this->assertEquals(Subsite::currentSubsiteID(), $subsite1->ID, 'Is redirected within subsite.');
$this->assertNotRegExp('#^admin/assets/.*#', $this->mainSession->lastUrl(),
'Is redirected away from forbidden section');
// Check forbidden site.
$this->getAndFollowAll("admin/pages/?SubsiteID=0");
$this->assertEquals(Subsite::currentSubsiteID(), $subsite1->ID, 'Is redirected to permitted subsite.');
// Check the standalone XHR controller.
$response = $this->getAndFollowAll('SubsiteXHRController');
$this->assertNotRegExp('#^Security/login.*#', $this->mainSession->lastUrl(),
'SubsiteXHRController is reachable');
}
}

View File

@ -241,7 +241,29 @@ class SubsiteTest extends BaseSubsiteTest {
$_SERVER['HTTP_HOST'] = $originalHTTPHost;
}
function testAllSites() {
$subsites = Subsite::all_sites();
$this->assertDOSEquals(array(
array('Title' =>'Main site'),
array('Title' =>'Template'),
array('Title' =>'Subsite1 Template'),
array('Title' =>'Subsite2 Template'),
array('Title' =>'Test 1'),
array('Title' =>'Test 2'),
array('Title' =>'Test 3')
), $subsites, 'Lists all subsites');
}
function testAllAccessibleSites() {
$member = $this->objFromFixture('Member', 'subsite1member');
$subsites = Subsite::all_accessible_sites(true, 'Main site', $member);
$this->assertDOSEquals(array(
array('Title' =>'Subsite1 Template')
), $subsites, 'Lists member-accessible sites.');
}
/**
* Test Subsite::accessible_sites()
*/