silverstripe-framework/src/Security/Group.php

615 lines
22 KiB
PHP
Executable File

<?php
namespace SilverStripe\Security;
use SilverStripe\Admin\SecurityAdmin;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter;
use SilverStripe\Forms\GridField\GridFieldDetailForm;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\TextareaField;
use SilverStripe\Forms\Tab;
use SilverStripe\Forms\TabSet;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\ListboxField;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig;
use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor;
use SilverStripe\Forms\GridField\GridFieldButtonRow;
use SilverStripe\Forms\GridField\GridFieldExportButton;
use SilverStripe\Forms\GridField\GridFieldPrintButton;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataQuery;
use SilverStripe\ORM\HasManyList;
use SilverStripe\ORM\Hierarchy\Hierarchy;
use SilverStripe\ORM\ManyManyList;
use SilverStripe\ORM\UnsavedRelationList;
use SilverStripe\View\Requirements;
/**
* A security group.
*
* @property string Title Name of the group
* @property string Description Description of the group
* @property string Code Group code
* @property string Locked Boolean indicating whether group is locked in security panel
* @property int Sort
* @property string HtmlEditorConfig
*
* @property int ParentID ID of parent group
*
* @method Group Parent() Return parent group
* @method HasManyList Permissions() List of group permissions
* @method HasManyList Groups() List of child groups
* @method ManyManyList Roles() List of PermissionRoles
* @mixin Hierarchy
*/
class Group extends DataObject
{
private static $db = array(
"Title" => "Varchar(255)",
"Description" => "Text",
"Code" => "Varchar(255)",
"Locked" => "Boolean",
"Sort" => "Int",
"HtmlEditorConfig" => "Text"
);
private static $has_one = array(
"Parent" => "SilverStripe\\Security\\Group",
);
private static $has_many = array(
"Permissions" => "SilverStripe\\Security\\Permission",
"Groups" => "SilverStripe\\Security\\Group"
);
private static $many_many = array(
"Members" => "SilverStripe\\Security\\Member",
"Roles" => "SilverStripe\\Security\\PermissionRole",
);
private static $extensions = array(
"SilverStripe\\ORM\\Hierarchy\\Hierarchy",
);
private static $table_name = "Group";
public function populateDefaults()
{
parent::populateDefaults();
if (!$this->Title) {
$this->Title = _t(__CLASS__.'.NEWGROUP', "New Group");
}
}
public function getAllChildren()
{
$doSet = new ArrayList();
$children = Group::get()->filter("ParentID", $this->ID);
foreach ($children as $child) {
$doSet->push($child);
$doSet->merge($child->getAllChildren());
}
return $doSet;
}
/**
* Caution: Only call on instances, not through a singleton.
* The "root group" fields will be created through {@link SecurityAdmin->EditForm()}.
*
* @return FieldList
*/
public function getCMSFields()
{
$fields = new FieldList(
new TabSet(
"Root",
new Tab(
'Members',
_t(__CLASS__.'.MEMBERS', 'Members'),
new TextField("Title", $this->fieldLabel('Title')),
$parentidfield = DropdownField::create(
'ParentID',
$this->fieldLabel('Parent'),
Group::get()->exclude('ID', $this->ID)->map('ID', 'Breadcrumbs')
)->setEmptyString(' '),
new TextareaField('Description', $this->fieldLabel('Description'))
),
$permissionsTab = new Tab(
'Permissions',
_t(__CLASS__.'.PERMISSIONS', 'Permissions'),
$permissionsField = new PermissionCheckboxSetField(
'Permissions',
false,
'SilverStripe\\Security\\Permission',
'GroupID',
$this
)
)
)
);
$parentidfield->setDescription(
_t('SilverStripe\\Security\\Group.GroupReminder', 'If you choose a parent group, this group will take all it\'s roles')
);
if ($this->ID) {
$group = $this;
$config = GridFieldConfig_RelationEditor::create();
$config->addComponent(new GridFieldButtonRow('after'));
$config->addComponents(new GridFieldExportButton('buttons-after-left'));
$config->addComponents(new GridFieldPrintButton('buttons-after-left'));
/** @var GridFieldAddExistingAutocompleter $autocompleter */
$autocompleter = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldAddExistingAutocompleter');
/** @skipUpgrade */
$autocompleter
->setResultsFormat('$Title ($Email)')
->setSearchFields(array('FirstName', 'Surname', 'Email'));
/** @var GridFieldDetailForm $detailForm */
$detailForm = $config->getComponentByType('SilverStripe\\Forms\\GridField\\GridFieldDetailForm');
$detailForm
->setValidator(Member_Validator::create())
->setItemEditFormCallback(function ($form, $component) use ($group) {
/** @var Form $form */
$record = $form->getRecord();
$groupsField = $form->Fields()->dataFieldByName('DirectGroups');
if ($groupsField) {
// If new records are created in a group context,
// set this group by default.
if ($record && !$record->ID) {
$groupsField->setValue($group->ID);
} elseif ($record && $record->ID) {
// TODO Mark disabled once chosen.js supports it
// $groupsField->setDisabledItems(array($group->ID));
$form->Fields()->replaceField(
'DirectGroups',
$groupsField->performReadonlyTransformation()
);
}
}
});
$memberList = GridField::create('Members', false, $this->DirectMembers(), $config)
->addExtraClass('members_grid');
// @todo Implement permission checking on GridField
//$memberList->setPermissions(array('edit', 'delete', 'export', 'add', 'inlineadd'));
$fields->addFieldToTab('Root.Members', $memberList);
}
// Only add a dropdown for HTML editor configurations if more than one is available.
// Otherwise Member->getHtmlEditorConfigForCMS() will default to the 'cms' configuration.
$editorConfigMap = HTMLEditorConfig::get_available_configs_map();
if (count($editorConfigMap) > 1) {
$fields->addFieldToTab(
'Root.Permissions',
new DropdownField(
'HtmlEditorConfig',
'HTML Editor Configuration',
$editorConfigMap
),
'Permissions'
);
}
if (!Permission::check('EDIT_PERMISSIONS')) {
$fields->removeFieldFromTab('Root', 'Permissions');
}
// Only show the "Roles" tab if permissions are granted to edit them,
// and at least one role exists
if (Permission::check('APPLY_ROLES') &&
PermissionRole::get()->count() &&
class_exists(SecurityAdmin::class)
) {
$fields->findOrMakeTab('Root.Roles', _t(__CLASS__.'.ROLES', 'Roles'));
$fields->addFieldToTab(
'Root.Roles',
new LiteralField(
"",
"<p>" .
_t(
__CLASS__.'.ROLESDESCRIPTION',
"Roles are predefined sets of permissions, and can be assigned to groups.<br />"
. "They are inherited from parent groups if required."
) . '<br />' .
sprintf(
'<a href="%s" class="add-role">%s</a>',
SecurityAdmin::singleton()->Link('show/root#Root_Roles'),
// TODO This should include #Root_Roles to switch directly to the tab,
// but tabstrip.js doesn't display tabs when directly adressed through a URL pragma
_t('SilverStripe\\Security\\Group.RolesAddEditLink', 'Manage roles')
) .
"</p>"
)
);
// Add roles (and disable all checkboxes for inherited roles)
$allRoles = PermissionRole::get();
if (!Permission::check('ADMIN')) {
$allRoles = $allRoles->filter("OnlyAdminCanApply", 0);
}
if ($this->ID) {
$groupRoles = $this->Roles();
$inheritedRoles = new ArrayList();
$ancestors = $this->getAncestors();
foreach ($ancestors as $ancestor) {
$ancestorRoles = $ancestor->Roles();
if ($ancestorRoles) {
$inheritedRoles->merge($ancestorRoles);
}
}
$groupRoleIDs = $groupRoles->column('ID') + $inheritedRoles->column('ID');
$inheritedRoleIDs = $inheritedRoles->column('ID');
} else {
$groupRoleIDs = array();
$inheritedRoleIDs = array();
}
$rolesField = ListboxField::create('Roles', false, $allRoles->map()->toArray())
->setDefaultItems($groupRoleIDs)
->setAttribute('data-placeholder', _t('SilverStripe\\Security\\Group.AddRole', 'Add a role for this group'))
->setDisabledItems($inheritedRoleIDs);
if (!$allRoles->count()) {
$rolesField->setAttribute('data-placeholder', _t('SilverStripe\\Security\\Group.NoRoles', 'No roles found'));
}
$fields->addFieldToTab('Root.Roles', $rolesField);
}
$fields->push($idField = new HiddenField("ID"));
$this->extend('updateCMSFields', $fields);
return $fields;
}
/**
* @param bool $includerelations Indicate if the labels returned include relation fields
* @return array
*/
public function fieldLabels($includerelations = true)
{
$labels = parent::fieldLabels($includerelations);
$labels['Title'] = _t(__CLASS__.'.GROUPNAME', 'Group name');
$labels['Description'] = _t('SilverStripe\\Security\\Group.Description', 'Description');
$labels['Code'] = _t('SilverStripe\\Security\\Group.Code', 'Group Code', 'Programmatical code identifying a group');
$labels['Locked'] = _t('SilverStripe\\Security\\Group.Locked', 'Locked?', 'Group is locked in the security administration area');
$labels['Sort'] = _t('SilverStripe\\Security\\Group.Sort', 'Sort Order');
if ($includerelations) {
$labels['Parent'] = _t('SilverStripe\\Security\\Group.Parent', 'Parent Group', 'One group has one parent group');
$labels['Permissions'] = _t('SilverStripe\\Security\\Group.has_many_Permissions', 'Permissions', 'One group has many permissions');
$labels['Members'] = _t('SilverStripe\\Security\\Group.many_many_Members', 'Members', 'One group has many members');
}
return $labels;
}
/**
* Get many-many relation to {@link Member},
* including all members which are "inherited" from children groups of this record.
* See {@link DirectMembers()} for retrieving members without any inheritance.
*
* @param String $filter
* @return ManyManyList
*/
public function Members($filter = '')
{
// First get direct members as a base result
$result = $this->DirectMembers();
// Unsaved group cannot have child groups because its ID is still 0.
if (!$this->exists()) {
return $result;
}
// Remove the default foreign key filter in prep for re-applying a filter containing all children groups.
// Filters are conjunctive in DataQuery by default, so this filter would otherwise overrule any less specific
// ones.
if (!($result instanceof UnsavedRelationList)) {
$result = $result->alterDataQuery(function ($query) {
/** @var DataQuery $query */
$query->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);
return $result;
}
/**
* Return only the members directly added to this group
*/
public function DirectMembers()
{
return $this->getManyManyComponents('Members');
}
/**
* Return a set of this record's "family" of IDs - the IDs of
* this record and all its descendants.
*
* @return array
*/
public function collateFamilyIDs()
{
if (!$this->exists()) {
throw new \InvalidArgumentException("Cannot call collateFamilyIDs on unsaved Group.");
}
$familyIDs = array();
$chunkToAdd = array($this->ID);
while ($chunkToAdd) {
$familyIDs = array_merge($familyIDs, $chunkToAdd);
// Get the children of *all* the groups identified in the previous chunk.
// This minimises the number of SQL queries necessary
$chunkToAdd = Group::get()->filter("ParentID", $chunkToAdd)->column('ID');
}
return $familyIDs;
}
/**
* Returns an array of the IDs of this group and all its parents
*
* @return array
*/
public function collateAncestorIDs()
{
$parent = $this;
$items = [];
while (isset($parent) && $parent instanceof Group) {
$items[] = $parent->ID;
$parent = $parent->Parent;
}
return $items;
}
/**
* This isn't a decendant of SiteTree, but needs this in case
* the group is "reorganised";
*/
public function cmsCleanup_parentChanged()
{
}
/**
* Override this so groups are ordered in the CMS
*/
public function stageChildren()
{
return Group::get()
->filter("ParentID", $this->ID)
->exclude("ID", $this->ID)
->sort('"Sort"');
}
public function getTreeTitle()
{
if ($this->hasMethod('alternateTreeTitle')) {
return $this->alternateTreeTitle();
}
return htmlspecialchars($this->Title, ENT_QUOTES);
}
/**
* Overloaded to ensure the code is always descent.
*
* @param string
*/
public function setCode($val)
{
$this->setField("Code", Convert::raw2url($val));
}
public function validate()
{
$result = parent::validate();
// Check if the new group hierarchy would add certain "privileged permissions",
// and require an admin to perform this change in case it does.
// This prevents "sub-admin" users with group editing permissions to increase their privileges.
if ($this->Parent()->exists() && !Permission::check('ADMIN')) {
$inheritedCodes = Permission::get()
->filter('GroupID', $this->Parent()->collateAncestorIDs())
->column('Code');
$privilegedCodes = Config::inst()->get('SilverStripe\\Security\\Permission', 'privileged_permissions');
if (array_intersect($inheritedCodes, $privilegedCodes)) {
$result->addError(sprintf(
_t(
'SilverStripe\\Security\\Group.HierarchyPermsError',
'Can\'t assign parent group "%s" with privileged permissions (requires ADMIN access)'
),
$this->Parent()->Title
));
}
}
return $result;
}
public function onBeforeWrite()
{
parent::onBeforeWrite();
// Only set code property when the group has a custom title, and no code exists.
// The "Code" attribute is usually treated as a more permanent identifier than database IDs
// in custom application logic, so can't be changed after its first set.
if (!$this->Code && $this->Title != _t(__CLASS__.'.NEWGROUP', "New Group")) {
$this->setCode($this->Title);
}
}
public function onBeforeDelete()
{
parent::onBeforeDelete();
// if deleting this group, delete it's children as well
foreach ($this->Groups() as $group) {
$group->delete();
}
// Delete associated permissions
foreach ($this->Permissions() as $permission) {
$permission->delete();
}
}
/**
* Checks for permission-code CMS_ACCESS_SecurityAdmin.
* If the group has ADMIN permissions, it requires the user to have ADMIN permissions as well.
*
* @param $member Member
* @return boolean
*/
public function canEdit($member = null)
{
if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) {
$member = Member::currentUser();
}
// extended access checks
$results = $this->extend('canEdit', $member);
if ($results && is_array($results)) {
if (!min($results)) {
return false;
}
}
if (// either we have an ADMIN
(bool)Permission::checkMember($member, "ADMIN")
|| (
// or a privileged CMS user and a group without ADMIN permissions.
// without this check, a user would be able to add himself to an administrators group
// with just access to the "Security" admin interface
Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin") &&
!Permission::get()->filter(array('GroupID' => $this->ID, 'Code' => 'ADMIN'))->exists()
)
) {
return true;
}
return false;
}
/**
* Checks for permission-code CMS_ACCESS_SecurityAdmin.
*
* @param $member Member
* @return boolean
*/
public function canView($member = null)
{
if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) {
$member = Member::currentUser();
}
// extended access checks
$results = $this->extend('canView', $member);
if ($results && is_array($results)) {
if (!min($results)) {
return false;
}
}
// user needs access to CMS_ACCESS_SecurityAdmin
if (Permission::checkMember($member, "CMS_ACCESS_SecurityAdmin")) {
return true;
}
return false;
}
public function canDelete($member = null)
{
if (!$member || !(is_a($member, 'SilverStripe\\Security\\Member')) || is_numeric($member)) {
$member = Member::currentUser();
}
// extended access checks
$results = $this->extend('canDelete', $member);
if ($results && is_array($results)) {
if (!min($results)) {
return false;
}
}
return $this->canEdit($member);
}
/**
* Returns all of the children for the CMS Tree.
* Filters to only those groups that the current user can edit
*/
public function AllChildrenIncludingDeleted()
{
/** @var Hierarchy $extInstance */
$extInstance = $this->getExtensionInstance('SilverStripe\\ORM\\Hierarchy\\Hierarchy');
$extInstance->setOwner($this);
$children = $extInstance->AllChildrenIncludingDeleted();
$extInstance->clearOwner();
$filteredChildren = new ArrayList();
if ($children) {
foreach ($children as $child) {
if ($child->canView()) {
$filteredChildren->push($child);
}
}
}
return $filteredChildren;
}
/**
* Add default records to database.
*
* This function is called whenever the database is built, after the
* database tables have all been created.
*/
public function requireDefaultRecords()
{
parent::requireDefaultRecords();
// Add default author group if no other group exists
$allGroups = DataObject::get('SilverStripe\\Security\\Group');
if (!$allGroups->count()) {
$authorGroup = new Group();
$authorGroup->Code = 'content-authors';
$authorGroup->Title = _t('SilverStripe\\Security\\Group.DefaultGroupTitleContentAuthors', 'Content Authors');
$authorGroup->Sort = 1;
$authorGroup->write();
Permission::grant($authorGroup->ID, 'CMS_ACCESS_CMSMain');
Permission::grant($authorGroup->ID, 'CMS_ACCESS_AssetAdmin');
Permission::grant($authorGroup->ID, 'CMS_ACCESS_ReportAdmin');
Permission::grant($authorGroup->ID, 'SITETREE_REORGANISE');
}
// Add default admin group if none with permission code ADMIN exists
$adminGroups = Permission::get_groups_by_permission('ADMIN');
if (!$adminGroups->count()) {
$adminGroup = new Group();
$adminGroup->Code = 'administrators';
$adminGroup->Title = _t('SilverStripe\\Security\\Group.DefaultGroupTitleAdministrators', 'Administrators');
$adminGroup->Sort = 0;
$adminGroup->write();
Permission::grant($adminGroup->ID, 'ADMIN');
}
// Members are populated through Member->requireDefaultRecords()
}
}