diff --git a/application/controllers/AuthenticationController.php b/application/controllers/AuthenticationController.php index 7d492f8c1..4d6fe9928 100644 --- a/application/controllers/AuthenticationController.php +++ b/application/controllers/AuthenticationController.php @@ -7,7 +7,7 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\AuthChain; -use Icinga\Authentication\Backend\ExternalBackend; +use Icinga\Authentication\User\ExternalBackend; use Icinga\Exception\AuthenticationException; use Icinga\Exception\ConfigurationError; use Icinga\Exception\NotReadableError; diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 5df2be008..9d55910e9 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -5,14 +5,15 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Modules\Module; use Icinga\Data\ResourceFactory; -use Icinga\Forms\Config\AuthenticationBackendConfigForm; -use Icinga\Forms\Config\AuthenticationBackendReorderForm; +use Icinga\Forms\Config\UserBackendConfigForm; +use Icinga\Forms\Config\UserBackendReorderForm; use Icinga\Forms\Config\GeneralConfigForm; use Icinga\Forms\Config\ResourceConfigForm; use Icinga\Forms\ConfirmRemovalForm; use Icinga\Security\SecurityException; use Icinga\Web\Controller; use Icinga\Web\Notification; +use Icinga\Web\Url; use Icinga\Web\Widget; /** @@ -38,20 +39,12 @@ class ConfigController extends Controller $auth = $this->Auth(); $allowedActions = array(); if ($auth->hasPermission('config/application/general')) { - $tabs->add('application', array( + $tabs->add('general', array( 'title' => $this->translate('Adjust the general configuration of Icinga Web 2'), - 'label' => $this->translate('Application'), - 'url' => 'config/application' + 'label' => $this->translate('General'), + 'url' => 'config/general' )); - $allowedActions[] = 'application'; - } - if ($auth->hasPermission('config/application/authentication')) { - $tabs->add('authentication', array( - 'title' => $this->translate('Configure how users authenticate with and log into Icinga Web 2'), - 'label' => $this->translate('Authentication'), - 'url' => 'config/authentication' - )); - $allowedActions[] = 'authentication'; + $allowedActions[] = 'general'; } if ($auth->hasPermission('config/application/resources')) { $tabs->add('resource', array( @@ -61,15 +54,21 @@ class ConfigController extends Controller )); $allowedActions[] = 'resource'; } - if ($auth->hasPermission('config/application/roles')) { - $tabs->add('roles', array( - 'title' => $this->translate( - 'Configure roles to permit or restrict users and groups accessing Icinga Web 2' - ), - 'label' => $this->translate('Roles'), - 'url' => 'roles' + if ($auth->hasPermission('config/application/userbackend')) { + $tabs->add('userbackend', array( + 'title' => $this->translate('Configure how users authenticate with and log into Icinga Web 2'), + 'label' => $this->translate('Authentication'), + 'url' => 'config/userbackend' )); - $allowedActions[] = 'roles'; + $allowedActions[] = 'userbackend'; + } + if ($auth->hasPermission('config/application/usergroupbackend')) { + $tabs->add('usergroupbackend', array( + 'title' => $this->translate('Configure how users are associated with groups by Icinga Web 2'), + 'label' => $this->translate('User Groups'), + 'url' => 'usergroupbackend/list' + )); + $allowedActions[] = 'usergroupbackend'; } $this->firstAllowedAction = array_shift($allowedActions); } @@ -85,7 +84,7 @@ class ConfigController extends Controller public function indexAction() { if ($this->firstAllowedAction === null) { - throw new SecurityException($this->translate('No permission for configuration')); + throw new SecurityException($this->translate('No permission for application configuration')); } $action = $this->getTabs()->get($this->firstAllowedAction); if (substr($action->getUrl()->getPath(), 0, 7) === 'config/') { @@ -96,11 +95,11 @@ class ConfigController extends Controller } /** - * Application configuration + * General configuration * - * @throws SecurityException If the user lacks the permission for configuring the application + * @throws SecurityException If the user lacks the permission for configuring the general configuration */ - public function applicationAction() + public function generalAction() { $this->assertPermission('config/application/general'); $form = new GeneralConfigForm(); @@ -108,7 +107,7 @@ class ConfigController extends Controller $form->handleRequest(); $this->view->form = $form; - $this->view->tabs->activate('application'); + $this->view->tabs->activate('general'); } /** @@ -201,71 +200,72 @@ class ConfigController extends Controller } /** - * Action for listing and reordering authentication backends + * Action for listing and reordering user backends */ - public function authenticationAction() + public function userbackendAction() { - $this->assertPermission('config/application/authentication'); - $form = new AuthenticationBackendReorderForm(); + $this->assertPermission('config/application/userbackend'); + $form = new UserBackendReorderForm(); $form->setIniConfig(Config::app('authentication')); $form->handleRequest(); $this->view->form = $form; - $this->view->tabs->activate('authentication'); - $this->render('authentication/reorder'); + $this->view->tabs->activate('userbackend'); + $this->render('userbackend/reorder'); } /** - * Action for creating a new authentication backend + * Action for creating a new user backend */ - public function createauthenticationbackendAction() + public function createuserbackendAction() { - $this->assertPermission('config/application/authentication'); - $form = new AuthenticationBackendConfigForm(); - $form->setTitle($this->translate('Create New Authentication Backend')); + $this->assertPermission('config/application/userbackend'); + $form = new UserBackendConfigForm(); + $form->setTitle($this->translate('Create New User Backend')); $form->addDescription($this->translate( 'Create a new backend for authenticating your users. This backend' . ' will be added at the end of your authentication order.' )); $form->setIniConfig(Config::app('authentication')); $form->setResourceConfig(ResourceFactory::getResourceConfigs()); - $form->setRedirectUrl('config/authentication'); + $form->setRedirectUrl('config/userbackend'); $form->handleRequest(); $this->view->form = $form; - $this->view->tabs->activate('authentication'); - $this->render('authentication/create'); + $this->view->tabs->activate('userbackend'); + $this->render('userbackend/create'); } /** - * Action for editing authentication backends + * Action for editing user backends */ - public function editauthenticationbackendAction() + public function edituserbackendAction() { - $this->assertPermission('config/application/authentication'); - $form = new AuthenticationBackendConfigForm(); - $form->setTitle($this->translate('Edit Backend')); + $this->assertPermission('config/application/userbackend'); + $form = new UserBackendConfigForm(); + $form->setTitle($this->translate('Edit User Backend')); $form->setIniConfig(Config::app('authentication')); $form->setResourceConfig(ResourceFactory::getResourceConfigs()); - $form->setRedirectUrl('config/authentication'); + $form->setRedirectUrl('config/userbackend'); + $form->setAction(Url::fromRequest()); $form->handleRequest(); $this->view->form = $form; - $this->view->tabs->activate('authentication'); - $this->render('authentication/modify'); + $this->view->tabs->activate('userbackend'); + $this->render('userbackend/modify'); } /** - * Action for removing a backend from the authentication list + * Action for removing a user backend */ - public function removeauthenticationbackendAction() + public function removeuserbackendAction() { - $this->assertPermission('config/application/authentication'); + $this->assertPermission('config/application/userbackend'); $form = new ConfirmRemovalForm(array( 'onSuccess' => function ($form) { - $configForm = new AuthenticationBackendConfigForm(); + $configForm = new UserBackendConfigForm(); $configForm->setIniConfig(Config::app('authentication')); - $authBackend = $form->getRequest()->getQuery('auth_backend'); + $authBackend = $form->getRequest()->getQuery('backend'); try { $configForm->remove($authBackend); @@ -276,7 +276,7 @@ class ConfigController extends Controller if ($configForm->save()) { Notification::success(sprintf( - t('Authentication backend "%s" has been successfully removed'), + t('User backend "%s" has been successfully removed'), $authBackend )); } else { @@ -284,13 +284,14 @@ class ConfigController extends Controller } } )); - $form->setTitle($this->translate('Remove Backend')); - $form->setRedirectUrl('config/authentication'); + $form->setTitle($this->translate('Remove User Backend')); + $form->setRedirectUrl('config/userbackend'); + $form->setAction(Url::fromRequest()); $form->handleRequest(); $this->view->form = $form; - $this->view->tabs->activate('authentication'); - $this->render('authentication/remove'); + $this->view->tabs->activate('userbackend'); + $this->render('userbackend/remove'); } /** @@ -373,7 +374,7 @@ class ConfigController extends Controller if ($config->get('resource') === $resource) { $form->addDescription(sprintf( $this->translate( - 'The resource "%s" is currently in use by the authentication backend "%s". ' . + 'The resource "%s" is currently utilized for authentication by user backend "%s". ' . 'Removing the resource can result in noone being able to log in any longer.' ), $resource, diff --git a/application/controllers/GroupController.php b/application/controllers/GroupController.php new file mode 100644 index 000000000..70954aaf1 --- /dev/null +++ b/application/controllers/GroupController.php @@ -0,0 +1,345 @@ +assertPermission('config/authentication/groups/show'); + $backendNames = array_map( + function ($b) { return $b->getName(); }, + $this->loadUserGroupBackends('Icinga\Data\Selectable') + ); + $this->view->backendSelection = new Form(); + $this->view->backendSelection->setAttrib('class', 'backend-selection'); + $this->view->backendSelection->setUidDisabled(); + $this->view->backendSelection->setMethod('GET'); + $this->view->backendSelection->setTokenDisabled(); + $this->view->backendSelection->addElement( + 'select', + 'backend', + array( + 'autosubmit' => true, + 'label' => $this->translate('Usergroup Backend'), + 'multiOptions' => array_combine($backendNames, $backendNames), + 'value' => $this->params->get('backend') + ) + ); + + $backend = $this->getUserGroupBackend($this->params->get('backend')); + if ($backend === null) { + $this->view->backend = null; + return; + } + + $query = $backend->select(array('group_name')); + $filterEditor = Widget::create('filterEditor') + ->setQuery($query) + ->preserveParams('limit', 'sort', 'dir', 'view', 'backend') + ->ignoreParams('page') + ->handleRequest($this->getRequest()); + $query->applyFilter($filterEditor->getFilter()); + $this->setupFilterControl($filterEditor); + + $this->view->groups = $query; + $this->view->backend = $backend; + $this->createListTabs()->activate('group/list'); + + $this->setupPaginationControl($query); + $this->setupLimitControl(); + $this->setupSortControl( + array( + 'group_name' => $this->translate('Group'), + 'created_at' => $this->translate('Created at'), + 'last_modified' => $this->translate('Last modified') + ), + $query + ); + } + + /** + * Show a group + */ + public function showAction() + { + $this->assertPermission('config/authentication/groups/show'); + $groupName = $this->params->getRequired('group'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend')); + + $group = $backend->select(array( + 'group_name', + 'created_at', + 'last_modified' + ))->where('group_name', $groupName)->fetchRow(); + if ($group === false) { + $this->httpNotFound(sprintf($this->translate('Group "%s" not found'), $groupName)); + } + + $members = $backend + ->select() + ->from('group_membership', array('user_name')) + ->where('group_name', $groupName); + + $filterEditor = Widget::create('filterEditor') + ->setQuery($members) + ->preserveParams('limit', 'sort', 'dir', 'view', 'backend', 'group') + ->ignoreParams('page') + ->handleRequest($this->getRequest()); + $members->applyFilter($filterEditor->getFilter()); + + $this->setupFilterControl($filterEditor); + $this->setupPaginationControl($members); + $this->setupLimitControl(); + $this->setupSortControl( + array( + 'user_name' => $this->translate('Username'), + 'created_at' => $this->translate('Created at'), + 'last_modified' => $this->translate('Last modified') + ), + $members + ); + + $this->view->group = $group; + $this->view->backend = $backend; + $this->view->members = $members; + $this->createShowTabs($backend->getName(), $groupName)->activate('group/show'); + + if ($this->hasPermission('config/authentication/groups/edit') && $backend instanceof Reducible) { + $removeForm = new Form(); + $removeForm->setUidDisabled(); + $removeForm->setAction( + Url::fromPath('group/removemember', array('backend' => $backend->getName(), 'group' => $groupName)) + ); + $removeForm->addElement('hidden', 'user_name', array( + 'isArray' => true, + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('hidden', 'redirect', array( + 'value' => Url::fromPath('group/show', array( + 'backend' => $backend->getName(), + 'group' => $groupName + )), + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('button', 'btn_submit', array( + 'escape' => false, + 'type' => 'submit', + 'class' => 'link-like', + 'value' => 'btn_submit', + 'decorators' => array('ViewHelper'), + 'label' => $this->view->icon('trash'), + 'title' => $this->translate('Remove this member') + )); + $this->view->removeForm = $removeForm; + } + } + + /** + * Add a group + */ + public function addAction() + { + $this->assertPermission('config/authentication/groups/add'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend'), 'Icinga\Data\Extensible'); + $form = new UserGroupForm(); + $form->setRedirectUrl(Url::fromPath('group/list', array('backend' => $backend->getName()))); + $form->setRepository($backend); + $form->add()->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Edit a group + */ + public function editAction() + { + $this->assertPermission('config/authentication/groups/edit'); + $groupName = $this->params->getRequired('group'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend'), 'Icinga\Data\Updatable'); + + $form = new UserGroupForm(); + $form->setRedirectUrl( + Url::fromPath('group/show', array('backend' => $backend->getName(), 'group' => $groupName)) + ); + $form->setRepository($backend); + + try { + $form->edit($groupName)->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Group "%s" not found'), $groupName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Remove a group + */ + public function removeAction() + { + $this->assertPermission('config/authentication/groups/remove'); + $groupName = $this->params->getRequired('group'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend'), 'Icinga\Data\Reducible'); + + $form = new UserGroupForm(); + $form->setRedirectUrl(Url::fromPath('group/list', array('backend' => $backend->getName()))); + $form->setRepository($backend); + + try { + $form->remove($groupName)->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Group "%s" not found'), $groupName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Add a group member + */ + public function addmemberAction() + { + $this->assertPermission('config/authentication/groups/edit'); + $groupName = $this->params->getRequired('group'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend'), 'Icinga\Data\Extensible'); + + $form = new AddMemberForm(); + $form->setDataSource($this->fetchUsers()) + ->setBackend($backend) + ->setGroupName($groupName) + ->setRedirectUrl( + Url::fromPath('group/show', array('backend' => $backend->getName(), 'group' => $groupName)) + ) + ->setUidDisabled(); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Group "%s" not found'), $groupName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Remove a group member + */ + public function removememberAction() + { + $this->assertPermission('config/authentication/groups/edit'); + $this->assertHttpMethod('POST'); + $groupName = $this->params->getRequired('group'); + $backend = $this->getUserGroupBackend($this->params->getRequired('backend'), 'Icinga\Data\Reducible'); + + $form = new Form(array( + 'onSuccess' => function ($form) use ($groupName, $backend) { + foreach ($form->getValue('user_name') as $userName) { + try { + $backend->delete( + 'group_membership', + Filter::matchAll( + Filter::where('group_name', $groupName), + Filter::where('user_name', $userName) + ) + ); + Notification::success(sprintf( + t('User "%s" has been removed from group "%s"'), + $userName, + $groupName + )); + } catch (NotFoundError $e) { + throw $e; + } catch (Exception $e) { + Notification::error($e->getMessage()); + } + } + + $redirect = $form->getValue('redirect'); + if (! empty($redirect)) { + $form->setRedirectUrl(htmlspecialchars_decode($redirect)); + } + + return true; + } + )); + $form->setUidDisabled(); + $form->setSubmitLabel('btn_submit'); // Required to ensure that isSubmitted() is called + $form->addElement('hidden', 'user_name', array('required' => true, 'isArray' => true)); + $form->addElement('hidden', 'redirect'); + + try { + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('Group "%s" not found'), $groupName)); + } + } + + /** + * Fetch and return all users from all user backends + * + * @return ArrayDatasource + */ + protected function fetchUsers() + { + $users = array(); + foreach ($this->loadUserBackends('Icinga\Data\Selectable') as $backend) { + try { + foreach ($backend->select(array('user_name')) as $row) { + $users[] = $row; + } + } catch (Exception $e) { + Logger::error($e); + Notification::warning(sprintf( + $this->translate('Failed to fetch any users from backend %s. Please check your log'), + $backend->getName() + )); + } + } + + return new ArrayDatasource($users); + } + + /** + * Create the tabs to display when showing a group + * + * @param string $backendName + * @param string $groupName + */ + protected function createShowTabs($backendName, $groupName) + { + $tabs = $this->getTabs(); + $tabs->add( + 'group/show', + array( + 'title' => sprintf($this->translate('Show group %s'), $groupName), + 'label' => $this->translate('Group'), + 'icon' => 'users', + 'url' => Url::fromPath('group/show', array('backend' => $backendName, 'group' => $groupName)) + ) + ); + + return $tabs; + } +} diff --git a/application/controllers/RolesController.php b/application/controllers/RoleController.php similarity index 68% rename from application/controllers/RolesController.php rename to application/controllers/RoleController.php index d4b7b7c63..0384009ec 100644 --- a/application/controllers/RolesController.php +++ b/application/controllers/RoleController.php @@ -4,69 +4,27 @@ use Icinga\Application\Config; use Icinga\Forms\ConfirmRemovalForm; use Icinga\Forms\Security\RoleForm; -use Icinga\Web\Controller\ActionController; +use Icinga\Web\Controller\AuthBackendController; use Icinga\Web\Notification; -use Icinga\Web\Widget; -/** - * Roles configuration - */ -class RolesController extends ActionController +class RoleController extends AuthBackendController { - /** - * Initialize tabs and validate the user's permissions - * - * @throws \Icinga\Security\SecurityException If the user lacks permissions for configuring roles - */ - public function init() - { - $this->assertPermission('config/application/roles'); - $tabs = $this->getTabs(); - $auth = $this->Auth(); - if ($auth->hasPermission('config/application/general')) { - $tabs->add('application', array( - 'title' => $this->translate('Adjust the general configuration of Icinga Web 2'), - 'label' => $this->translate('Application'), - 'url' => 'config' - )); - } - if ($auth->hasPermission('config/application/authentication')) { - $tabs->add('authentication', array( - 'title' => $this->translate('Configure how users authenticate with and log into Icinga Web 2'), - 'label' => $this->translate('Authentication'), - 'url' => 'config/authentication' - )); - } - if ($auth->hasPermission('config/application/resources')) { - $tabs->add('resource', array( - 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), - 'label' => $this->translate('Resources'), - 'url' => 'config/resource' - )); - } - $tabs->add('roles', array( - 'title' => $this->translate( - 'Configure roles to permit or restrict users and groups accessing Icinga Web 2' - ), - 'label' => $this->translate('Roles'), - 'url' => 'roles' - )); - } - /** * List roles */ - public function indexAction() + public function listAction() { - $this->view->tabs->activate('roles'); + $this->assertPermission('config/authentication/roles/show'); + $this->createListTabs()->activate('role/list'); $this->view->roles = Config::app('roles', true); } /** * Create a new role */ - public function newAction() + public function addAction() { + $this->assertPermission('config/authentication/roles/add'); $role = new RoleForm(array( 'onSuccess' => function (RoleForm $role) { $name = $role->getElement('name')->getValue(); @@ -88,9 +46,10 @@ class RolesController extends ActionController ->setTitle($this->translate('New Role')) ->setSubmitLabel($this->translate('Create Role')) ->setIniConfig(Config::app('roles', true)) - ->setRedirectUrl('roles') + ->setRedirectUrl('role/list') ->handleRequest(); $this->view->form = $role; + $this->render('form'); } /** @@ -98,8 +57,9 @@ class RolesController extends ActionController * * @throws Zend_Controller_Action_Exception If the required parameter 'role' is missing or the role does not exist */ - public function updateAction() + public function editAction() { + $this->assertPermission('config/authentication/roles/edit'); $name = $this->_request->getParam('role'); if (empty($name)) { throw new Zend_Controller_Action_Exception( @@ -137,9 +97,10 @@ class RolesController extends ActionController } return false; }) - ->setRedirectUrl('roles') + ->setRedirectUrl('role/list') ->handleRequest(); $this->view->form = $role; + $this->render('form'); } /** @@ -149,6 +110,7 @@ class RolesController extends ActionController */ public function removeAction() { + $this->assertPermission('config/authentication/roles/remove'); $name = $this->_request->getParam('role'); if (empty($name)) { throw new Zend_Controller_Action_Exception( @@ -185,8 +147,9 @@ class RolesController extends ActionController $confirmation ->setTitle(sprintf($this->translate('Remove Role %s'), $name)) ->setSubmitLabel($this->translate('Remove Role')) - ->setRedirectUrl('roles') + ->setRedirectUrl('role/list') ->handleRequest(); $this->view->form = $confirmation; + $this->render('form'); } } diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php new file mode 100644 index 000000000..c71fe9696 --- /dev/null +++ b/application/controllers/UserController.php @@ -0,0 +1,304 @@ +assertPermission('config/authentication/users/show'); + $backendNames = array_map( + function ($b) { return $b->getName(); }, + $this->loadUserBackends('Icinga\Data\Selectable') + ); + $this->view->backendSelection = new Form(); + $this->view->backendSelection->setAttrib('class', 'backend-selection'); + $this->view->backendSelection->setUidDisabled(); + $this->view->backendSelection->setMethod('GET'); + $this->view->backendSelection->setTokenDisabled(); + $this->view->backendSelection->addElement( + 'select', + 'backend', + array( + 'autosubmit' => true, + 'label' => $this->translate('Authentication Backend'), + 'multiOptions' => array_combine($backendNames, $backendNames), + 'value' => $this->params->get('backend') + ) + ); + + $backend = $this->getUserBackend($this->params->get('backend')); + if ($backend === null) { + $this->view->backend = null; + return; + } + + $query = $backend->select(array('user_name')); + $filterEditor = Widget::create('filterEditor') + ->setQuery($query) + ->preserveParams('limit', 'sort', 'dir', 'view', 'backend') + ->ignoreParams('page') + ->handleRequest($this->getRequest()); + $query->applyFilter($filterEditor->getFilter()); + $this->setupFilterControl($filterEditor); + + $this->view->users = $query; + $this->view->backend = $backend; + $this->createListTabs()->activate('user/list'); + + $this->setupPaginationControl($query); + $this->setupLimitControl(); + $this->setupSortControl( + array( + 'user_name' => $this->translate('Username'), + 'is_active' => $this->translate('Active'), + 'created_at' => $this->translate('Created at'), + 'last_modified' => $this->translate('Last modified') + ), + $query + ); + } + + /** + * Show a user + */ + public function showAction() + { + $this->assertPermission('config/authentication/users/show'); + $userName = $this->params->getRequired('user'); + $backend = $this->getUserBackend($this->params->getRequired('backend')); + + $user = $backend->select(array( + 'user_name', + 'is_active', + 'created_at', + 'last_modified' + ))->where('user_name', $userName)->fetchRow(); + if ($user === false) { + $this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); + } + + $memberships = $this->loadMemberships(new User($userName))->select(); + + $filterEditor = Widget::create('filterEditor') + ->setQuery($memberships) + ->preserveParams('limit', 'sort', 'dir', 'view', 'backend', 'user') + ->ignoreParams('page') + ->handleRequest($this->getRequest()); + $memberships->applyFilter($filterEditor->getFilter()); + + $this->setupFilterControl($filterEditor); + $this->setupPaginationControl($memberships); + $this->setupLimitControl(); + $this->setupSortControl( + array( + 'group_name' => $this->translate('Group') + ), + $memberships + ); + + if ($this->hasPermission('config/authentication/groups/edit')) { + $extensibleBackends = $this->loadUserGroupBackends('Icinga\Data\Extensible'); + $this->view->showCreateMembershipLink = ! empty($extensibleBackends); + } else { + $this->view->showCreateMembershipLink = false; + } + + $this->view->user = $user; + $this->view->backend = $backend; + $this->view->memberships = $memberships; + $this->createShowTabs($backend->getName(), $userName)->activate('user/show'); + + if ($this->hasPermission('config/authentication/groups/edit')) { + $removeForm = new Form(); + $removeForm->setUidDisabled(); + $removeForm->addElement('hidden', 'user_name', array( + 'isArray' => true, + 'value' => $userName, + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('hidden', 'redirect', array( + 'value' => Url::fromPath('user/show', array( + 'backend' => $backend->getName(), + 'user' => $userName + )), + 'decorators' => array('ViewHelper') + )); + $removeForm->addElement('button', 'btn_submit', array( + 'escape' => false, + 'type' => 'submit', + 'class' => 'link-like', + 'value' => 'btn_submit', + 'decorators' => array('ViewHelper'), + 'label' => $this->view->icon('trash'), + 'title' => $this->translate('Cancel this membership') + )); + $this->view->removeForm = $removeForm; + } + } + + /** + * Add a user + */ + public function addAction() + { + $this->assertPermission('config/authentication/users/add'); + $backend = $this->getUserBackend($this->params->getRequired('backend'), 'Icinga\Data\Extensible'); + $form = new UserForm(); + $form->setRedirectUrl(Url::fromPath('user/list', array('backend' => $backend->getName()))); + $form->setRepository($backend); + $form->add()->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Edit a user + */ + public function editAction() + { + $this->assertPermission('config/authentication/users/edit'); + $userName = $this->params->getRequired('user'); + $backend = $this->getUserBackend($this->params->getRequired('backend'), 'Icinga\Data\Updatable'); + + $form = new UserForm(); + $form->setRedirectUrl(Url::fromPath('user/show', array('backend' => $backend->getName(), 'user' => $userName))); + $form->setRepository($backend); + + try { + $form->edit($userName)->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Remove a user + */ + public function removeAction() + { + $this->assertPermission('config/authentication/users/remove'); + $userName = $this->params->getRequired('user'); + $backend = $this->getUserBackend($this->params->getRequired('backend'), 'Icinga\Data\Reducible'); + + $form = new UserForm(); + $form->setRedirectUrl(Url::fromPath('user/list', array('backend' => $backend->getName()))); + $form->setRepository($backend); + + try { + $form->remove($userName)->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Create a membership for a user + */ + public function createmembershipAction() + { + $this->assertPermission('config/authentication/groups/edit'); + $userName = $this->params->getRequired('user'); + $backend = $this->getUserBackend($this->params->getRequired('backend')); + + if ($backend->select()->where('user_name', $userName)->count() === 0) { + $this->httpNotFound(sprintf($this->translate('User "%s" not found'), $userName)); + } + + $backends = $this->loadUserGroupBackends('Icinga\Data\Extensible'); + if (empty($backends)) { + throw new ConfigurationError($this->translate( + 'You\'ll need to configure at least one user group backend first that allows to create new memberships' + )); + } + + $form = new CreateMembershipForm(); + $form->setBackends($backends) + ->setUsername($userName) + ->setRedirectUrl(Url::fromPath('user/show', array('backend' => $backend->getName(), 'user' => $userName))) + ->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Fetch and return the given user's groups from all user group backends + * + * @param User $user + * + * @return ArrayDatasource + */ + protected function loadMemberships(User $user) + { + $groups = $alreadySeen = array(); + foreach ($this->loadUserGroupBackends() as $backend) { + try { + foreach ($backend->getMemberships($user) as $groupName) { + if (array_key_exists($groupName, $alreadySeen)) { + continue; // Ignore duplicate memberships + } + + $alreadySeen[$groupName] = null; + $groups[] = (object) array( + 'group_name' => $groupName, + 'backend' => $backend + ); + } + } catch (Exception $e) { + Logger::error($e); + Notification::warning(sprintf( + $this->translate('Failed to fetch memberships from backend %s. Please check your log'), + $backend->getName() + )); + } + } + + return new ArrayDatasource($groups); + } + + /** + * Create the tabs to display when showing a user + * + * @param string $backendName + * @param string $userName + */ + protected function createShowTabs($backendName, $userName) + { + $tabs = $this->getTabs(); + $tabs->add( + 'user/show', + array( + 'title' => sprintf($this->translate('Show user %s'), $userName), + 'label' => $this->translate('User'), + 'icon' => 'user', + 'url' => Url::fromPath('user/show', array('backend' => $backendName, 'user' => $userName)) + ) + ); + + return $tabs; + } +} diff --git a/application/controllers/UsergroupbackendController.php b/application/controllers/UsergroupbackendController.php new file mode 100644 index 000000000..cdb6826be --- /dev/null +++ b/application/controllers/UsergroupbackendController.php @@ -0,0 +1,183 @@ +assertPermission('config/application/usergroupbackend'); + } + + /** + * Redirect to this controller's list action + */ + public function indexAction() + { + $this->redirectNow('usergroupbackend/list'); + } + + /** + * Show a list of all user group backends + */ + public function listAction() + { + $this->view->backendNames = Config::app('groups')->keys(); + $this->createListTabs()->activate('usergroupbackend'); + } + + /** + * Create a new user group backend + */ + public function createAction() + { + $form = new UserGroupBackendForm(); + $form->setRedirectUrl('usergroupbackend/list'); + $form->setTitle($this->translate('Create New User Group Backend')); + $form->addDescription($this->translate('Create a new backend to associate users and groups with.')); + $form->setIniConfig(Config::app('groups')); + $form->setOnSuccess(function (UserGroupBackendForm $form) { + try { + $form->add($form->getValues()); + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($form->save()) { + Notification::success(t('User group backend successfully created')); + return true; + } + + return false; + }); + $form->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Edit an user group backend + */ + public function editAction() + { + $backendName = $this->params->getRequired('backend'); + + $form = new UserGroupBackendForm(); + $form->setAction(Url::fromRequest()); + $form->setRedirectUrl('usergroupbackend/list'); + $form->setTitle(sprintf($this->translate('Edit User Group Backend %s'), $backendName)); + $form->setIniConfig(Config::app('groups')); + $form->setOnSuccess(function (UserGroupBackendForm $form) use ($backendName) { + try { + $form->edit($backendName, $form->getValues()); + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($form->save()) { + Notification::success(sprintf(t('User group backend "%s" successfully updated'), $backendName)); + return true; + } + + return false; + }); + + try { + $form->load($backendName); + $form->handleRequest(); + } catch (NotFoundError $_) { + $this->httpNotFound(sprintf($this->translate('User group backend "%s" not found'), $backendName)); + } + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Remove a user group backend + */ + public function removeAction() + { + $backendName = $this->params->getRequired('backend'); + + $backendForm = new UserGroupBackendForm(); + $backendForm->setIniConfig(Config::app('groups')); + $form = new ConfirmRemovalForm(); + $form->setRedirectUrl('usergroupbackend/list'); + $form->setTitle(sprintf($this->translate('Remove User Group Backend %s'), $backendName)); + $form->setOnSuccess(function (ConfirmRemovalForm $form) use ($backendName, $backendForm) { + try { + $backendForm->delete($backendName); + } catch (Exception $e) { + $form->error($e->getMessage()); + return false; + } + + if ($backendForm->save()) { + Notification::success(sprintf(t('User group backend "%s" successfully removed'), $backendName)); + return true; + } + + return false; + }); + $form->handleRequest(); + + $this->view->form = $form; + $this->render('form'); + } + + /** + * Create the tabs for the application configuration + */ + protected function createListTabs() + { + $tabs = $this->getTabs(); + if ($this->hasPermission('config/application/general')) { + $tabs->add('general', array( + 'title' => $this->translate('Adjust the general configuration of Icinga Web 2'), + 'label' => $this->translate('General'), + 'url' => 'config/general' + )); + } + if ($this->hasPermission('config/application/resources')) { + $tabs->add('resource', array( + 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), + 'label' => $this->translate('Resources'), + 'url' => 'config/resource' + )); + } + if ($this->hasPermission('config/application/userbackend')) { + $tabs->add('userbackend', array( + 'title' => $this->translate('Configure how users authenticate with and log into Icinga Web 2'), + 'label' => $this->translate('Authentication'), + 'url' => 'config/userbackend' + )); + } + if ($this->hasPermission('config/application/usergroupbackend')) { + $tabs->add('usergroupbackend', array( + 'title' => $this->translate('Configure how users are associated with groups by Icinga Web 2'), + 'label' => $this->translate('User Groups'), + 'url' => 'usergroupbackend/list' + )); + } + + return $tabs; + } +} diff --git a/application/forms/Config/User/CreateMembershipForm.php b/application/forms/Config/User/CreateMembershipForm.php new file mode 100644 index 000000000..a0b40b4ee --- /dev/null +++ b/application/forms/Config/User/CreateMembershipForm.php @@ -0,0 +1,191 @@ +backends = $backends; + return $this; + } + + /** + * Set the username to create memberships for + * + * @param string $userName + * + * @return $this + */ + public function setUsername($userName) + { + $this->userName = $userName; + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData The data sent by the user + */ + public function createElements(array $formData) + { + $query = $this->createDataSource()->select()->from('group', array('group_name', 'backend_name')); + + $options = array(); + foreach ($query as $row) { + $options[$row->backend_name . ';' . $row->group_name] = $row->group_name . ' (' . $row->backend_name . ')'; + } + + $this->addElement( + 'multiselect', + 'groups', + array( + 'required' => true, + 'multiOptions' => $options, + 'label' => $this->translate('Groups'), + 'description' => sprintf( + $this->translate('Select one or more groups where to add %s as member'), + $this->userName + ), + 'class' => 'grant-permissions' + ) + ); + + $this->setTitle(sprintf($this->translate('Create memberships for %s'), $this->userName)); + $this->setSubmitLabel($this->translate('Create')); + } + + /** + * Instantly redirect back in case the user is already a member of all groups + */ + public function onRequest() + { + if ($this->createDataSource()->select()->from('group')->count() === 0) { + Notification::info(sprintf($this->translate('User %s is already a member of all groups'), $this->userName)); + $this->getResponse()->redirectAndExit($this->getRedirectUrl()); + } + } + + /** + * Create the memberships for the user + * + * @return bool + */ + public function onSuccess() + { + $backendMap = array(); + foreach ($this->backends as $backend) { + $backendMap[$backend->getName()] = $backend; + } + + $single = null; + foreach ($this->getValue('groups') as $backendAndGroup) { + list($backendName, $groupName) = explode(';', $backendAndGroup, 2); + try { + $backendMap[$backendName]->insert( + 'group_membership', + array( + 'group_name' => $groupName, + 'user_name' => $this->userName + ) + ); + } catch (Exception $e) { + Notification::error(sprintf( + $this->translate('Failed to add "%s" as group member for "%s"'), + $this->userName, + $groupName + )); + $this->error($e->getMessage()); + return false; + } + + $single = $single === null; + } + + if ($single) { + Notification::success( + sprintf($this->translate('Membership for group %s created successfully'), $groupName) + ); + } else { + Notification::success($this->translate('Memberships created successfully')); + } + + return true; + } + + /** + * Create and return a data source to fetch all groups from all backends where the user is not already a member of + * + * @return ArrayDatasource + */ + protected function createDataSource() + { + $groups = $failures = array(); + foreach ($this->backends as $backend) { + try { + $memberships = $backend + ->select() + ->from('group_membership', array('group_name')) + ->where('user_name', $this->userName) + ->fetchColumn(); + foreach ($backend->select(array('group_name')) as $row) { + if (! in_array($row->group_name, $memberships)) { // TODO(jom): Apply this as native query filter + $row->backend_name = $backend->getName(); + $groups[] = $row; + } + } + } catch (Exception $e) { + $failures[] = array($backend->getName(), $e); + } + } + + if (empty($groups) && !empty($failures)) { + // In case there are only failures, throw the very first exception again + throw $failures[0][1]; + } elseif (! empty($failures)) { + foreach ($failures as $failure) { + Logger::error($failure[1]); + Notification::warning(sprintf( + $this->translate('Failed to fetch any groups from backend %s. Please check your log'), + $failure[0] + )); + } + } + + return new ArrayDatasource($groups); + } +} diff --git a/application/forms/Config/User/UserForm.php b/application/forms/Config/User/UserForm.php new file mode 100644 index 000000000..765b95882 --- /dev/null +++ b/application/forms/Config/User/UserForm.php @@ -0,0 +1,175 @@ +addElement( + 'checkbox', + 'is_active', + array( + 'required' => true, + 'value' => true, + 'label' => $this->translate('Active'), + 'description' => $this->translate('Prevents the user from logging in if unchecked') + ) + ); + $this->addElement( + 'text', + 'user_name', + array( + 'required' => true, + 'label' => $this->translate('Username') + ) + ); + $this->addElement( + 'text', + 'password', + array( + 'required' => true, + 'label' => $this->translate('Password') + ) + ); + + $this->setTitle($this->translate('Add a new user')); + $this->setSubmitLabel($this->translate('Add')); + } + + /** + * Create and add elements to this form to update a user + * + * @param array $formData The data sent by the user + */ + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + + $this->addElement( + 'text', + 'password', + array( + 'label' => $this->translate('Password') + ) + ); + + $this->setTitle(sprintf($this->translate('Edit user %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + /** + * Update a user + * + * @return bool + */ + protected function onUpdateSuccess() + { + if (parent::onUpdateSuccess()) { + if (($newName = $this->getValue('user_name')) !== $this->getIdentifier()) { + $this->getRedirectUrl()->setParam('user', $newName); + } + + return true; + } + + return false; + } + + /** + * Retrieve all form element values + * + * Strips off the password if null or the empty string. + * + * @param bool $suppressArrayNotation + * + * @return array + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + if (! $values['password']) { + unset($values['password']); + } + + return $values; + } + + /** + * Create and add elements to this form to delete a user + * + * @param array $formData The data sent by the user + */ + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove user %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + } + + /** + * Create and return a filter to use when updating or deleting a user + * + * @return Filter + */ + protected function createFilter() + { + return Filter::where('user_name', $this->getIdentifier()); + } + + /** + * Return a notification message to use when inserting a user + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getInsertMessage($success) + { + if ($success) { + return $this->translate('User added successfully'); + } else { + return $this->translate('Failed to add user'); + } + } + + /** + * Return a notification message to use when updating a user + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getUpdateMessage($success) + { + if ($success) { + return sprintf($this->translate('User "%s" has been edited'), $this->getIdentifier()); + } else { + return sprintf($this->translate('Failed to edit user "%s"'), $this->getIdentifier()); + } + } + + /** + * Return a notification message to use when deleting a user + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getDeleteMessage($success) + { + if ($success) { + return sprintf($this->translate('User "%s" has been removed'), $this->getIdentifier()); + } else { + return sprintf($this->translate('Failed to remove user "%s"'), $this->getIdentifier()); + } + } +} diff --git a/application/forms/Config/Authentication/DbBackendForm.php b/application/forms/Config/UserBackend/DbBackendForm.php similarity index 85% rename from application/forms/Config/Authentication/DbBackendForm.php rename to application/forms/Config/UserBackend/DbBackendForm.php index e96b9637c..f096f3e35 100644 --- a/application/forms/Config/Authentication/DbBackendForm.php +++ b/application/forms/Config/UserBackend/DbBackendForm.php @@ -1,16 +1,16 @@ getResourceConfig())); - if ($dbUserBackend->count() < 1) { - $form->addError($form->translate('No users found under the specified database backend')); + if ($dbUserBackend->select()->where('is_active', true)->count() < 1) { + $form->addError($form->translate('No active users found under the specified database backend')); return false; } } catch (Exception $e) { diff --git a/application/forms/Config/Authentication/ExternalBackendForm.php b/application/forms/Config/UserBackend/ExternalBackendForm.php similarity index 93% rename from application/forms/Config/Authentication/ExternalBackendForm.php rename to application/forms/Config/UserBackend/ExternalBackendForm.php index 86087eb66..4f3a4585f 100644 --- a/application/forms/Config/Authentication/ExternalBackendForm.php +++ b/application/forms/Config/UserBackend/ExternalBackendForm.php @@ -1,13 +1,13 @@ getResourceConfig()), - $form->getElement('user_class')->getValue(), - $form->getElement('user_name_attribute')->getValue(), - $form->getElement('base_dn')->getValue(), - $form->getElement('filter')->getValue() - ); + $ldapUserBackend = new LdapUserBackend(ResourceFactory::createResource($form->getResourceConfig())); + $ldapUserBackend->setConfig(new ConfigObject($form->getValues())); $ldapUserBackend->assertAuthenticationPossible(); } catch (AuthenticationException $e) { if (($previous = $e->getPrevious()) !== null) { diff --git a/application/forms/Config/AuthenticationBackendConfigForm.php b/application/forms/Config/UserBackendConfigForm.php similarity index 87% rename from application/forms/Config/AuthenticationBackendConfigForm.php rename to application/forms/Config/UserBackendConfigForm.php index 86322902b..0a30dd590 100644 --- a/application/forms/Config/AuthenticationBackendConfigForm.php +++ b/application/forms/Config/UserBackendConfigForm.php @@ -11,11 +11,11 @@ use Icinga\Application\Platform; use Icinga\Data\ConfigObject; use Icinga\Data\ResourceFactory; use Icinga\Exception\ConfigurationError; -use Icinga\Forms\Config\Authentication\DbBackendForm; -use Icinga\Forms\Config\Authentication\LdapBackendForm; -use Icinga\Forms\Config\Authentication\ExternalBackendForm; +use Icinga\Forms\Config\UserBackend\DbBackendForm; +use Icinga\Forms\Config\UserBackend\LdapBackendForm; +use Icinga\Forms\Config\UserBackend\ExternalBackendForm; -class AuthenticationBackendConfigForm extends ConfigForm +class UserBackendConfigForm extends ConfigForm { /** * The available resources split by type @@ -76,7 +76,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Add a particular authentication backend + * Add a particular user backend * * The backend to add is identified by the array-key `name'. * @@ -90,9 +90,9 @@ class AuthenticationBackendConfigForm extends ConfigForm { $name = isset($values['name']) ? $values['name'] : ''; if (! $name) { - throw new InvalidArgumentException($this->translate('Authentication backend name missing')); + throw new InvalidArgumentException($this->translate('User backend name missing')); } elseif ($this->config->hasSection($name)) { - throw new InvalidArgumentException($this->translate('Authentication backend already exists')); + throw new InvalidArgumentException($this->translate('User backend already exists')); } unset($values['name']); @@ -101,7 +101,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Edit a particular authentication backend + * Edit a particular user backend * * @param string $name The name of the backend to edit * @param array $values The values to edit the configuration with @@ -113,11 +113,11 @@ class AuthenticationBackendConfigForm extends ConfigForm public function edit($name, array $values) { if (! $name) { - throw new InvalidArgumentException($this->translate('Old authentication backend name missing')); + throw new InvalidArgumentException($this->translate('Old user backend name missing')); } elseif (! ($newName = isset($values['name']) ? $values['name'] : '')) { - throw new InvalidArgumentException($this->translate('New authentication backend name missing')); + throw new InvalidArgumentException($this->translate('New user backend name missing')); } elseif (! $this->config->hasSection($name)) { - throw new InvalidArgumentException($this->translate('Unknown authentication backend provided')); + throw new InvalidArgumentException($this->translate('Unknown user backend provided')); } $backendConfig = $this->config->getSection($name); @@ -132,7 +132,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Remove the given authentication backend + * Remove the given user backend * * @param string $name The name of the backend to remove * @@ -143,9 +143,9 @@ class AuthenticationBackendConfigForm extends ConfigForm public function remove($name) { if (! $name) { - throw new InvalidArgumentException($this->translate('Authentication backend name missing')); + throw new InvalidArgumentException($this->translate('user backend name missing')); } elseif (! $this->config->hasSection($name)) { - throw new InvalidArgumentException($this->translate('Unknown authentication backend provided')); + throw new InvalidArgumentException($this->translate('Unknown user backend provided')); } $backendConfig = $this->config->getSection($name); @@ -154,7 +154,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Move the given authentication backend up or down in order + * Move the given user backend up or down in order * * @param string $name The name of the backend to be moved * @param int $position The new (absolute) position of the backend @@ -166,9 +166,9 @@ class AuthenticationBackendConfigForm extends ConfigForm public function move($name, $position) { if (! $name) { - throw new InvalidArgumentException($this->translate('Authentication backend name missing')); + throw new InvalidArgumentException($this->translate('User backend name missing')); } elseif (! $this->config->hasSection($name)) { - throw new InvalidArgumentException($this->translate('Unknown authentication backend provided')); + throw new InvalidArgumentException($this->translate('Unknown user backend provided')); } $backendOrder = $this->config->keys(); @@ -186,7 +186,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Add or edit an authentication backend and save the configuration + * Add or edit an user backend and save the configuration * * Performs a connectivity validation using the submitted values. A checkbox is * added to the form to skip the check if it fails and redirection is aborted. @@ -197,20 +197,20 @@ class AuthenticationBackendConfigForm extends ConfigForm { if (($el = $this->getElement('force_creation')) === null || false === $el->isChecked()) { $backendForm = $this->getBackendForm($this->getElement('type')->getValue()); - if (false === $backendForm::isValidAuthenticationBackend($this)) { + if (false === $backendForm::isValidUserBackend($this)) { $this->addElement($this->getForceCreationCheckbox()); return false; } } - $authBackend = $this->request->getQuery('auth_backend'); + $authBackend = $this->request->getQuery('backend'); try { if ($authBackend === null) { // create new backend $this->add($this->getValues()); - $message = $this->translate('Authentication backend "%s" has been successfully created'); + $message = $this->translate('User backend "%s" has been successfully created'); } else { // edit existing backend $this->edit($authBackend, $this->getValues()); - $message = $this->translate('Authentication backend "%s" has been successfully changed'); + $message = $this->translate('User backend "%s" has been successfully changed'); } } catch (InvalidArgumentException $e) { Notification::error($e->getMessage()); @@ -225,7 +225,7 @@ class AuthenticationBackendConfigForm extends ConfigForm } /** - * Populate the form in case an authentication backend is being edited + * Populate the form in case an user backend is being edited * * @see Form::onRequest() * @@ -233,12 +233,12 @@ class AuthenticationBackendConfigForm extends ConfigForm */ public function onRequest() { - $authBackend = $this->request->getQuery('auth_backend'); + $authBackend = $this->request->getQuery('backend'); if ($authBackend !== null) { if ($authBackend === '') { - throw new ConfigurationError($this->translate('Authentication backend name missing')); + throw new ConfigurationError($this->translate('User backend name missing')); } elseif (! $this->config->hasSection($authBackend)) { - throw new ConfigurationError($this->translate('Unknown authentication backend provided')); + throw new ConfigurationError($this->translate('Unknown user backend provided')); } elseif ($this->config->getSection($authBackend)->backend === null) { throw new ConfigurationError( sprintf($this->translate('Backend "%s" has no `backend\' setting'), $authBackend) diff --git a/application/forms/Config/AuthenticationBackendReorderForm.php b/application/forms/Config/UserBackendReorderForm.php similarity index 87% rename from application/forms/Config/AuthenticationBackendReorderForm.php rename to application/forms/Config/UserBackendReorderForm.php index 34f20d851..9069e73e3 100644 --- a/application/forms/Config/AuthenticationBackendReorderForm.php +++ b/application/forms/Config/UserBackendReorderForm.php @@ -7,7 +7,7 @@ use InvalidArgumentException; use Icinga\Web\Notification; use Icinga\Forms\ConfigForm; -class AuthenticationBackendReorderForm extends ConfigForm +class UserBackendReorderForm extends ConfigForm { /** * Initialize this form @@ -38,7 +38,7 @@ class AuthenticationBackendReorderForm extends ConfigForm } /** - * Update the authentication backend order and save the configuration + * Update the user backend order and save the configuration * * @see Form::onSuccess() */ @@ -62,13 +62,13 @@ class AuthenticationBackendReorderForm extends ConfigForm } /** - * Return the config form for authentication backends + * Return the config form for user backends * * @return ConfigForm */ protected function getConfigForm() { - $form = new AuthenticationBackendConfigForm(); + $form = new UserBackendConfigForm(); $form->setIniConfig($this->config); return $form; } diff --git a/application/forms/Config/UserGroup/AddMemberForm.php b/application/forms/Config/UserGroup/AddMemberForm.php new file mode 100644 index 000000000..88064dcdd --- /dev/null +++ b/application/forms/Config/UserGroup/AddMemberForm.php @@ -0,0 +1,182 @@ +ds = $ds; + return $this; + } + + /** + * Set the user group backend to use + * + * @param Extensible $backend + * + * @return $this + */ + public function setBackend(Extensible $backend) + { + $this->backend = $backend; + return $this; + } + + /** + * Set the group to add members for + * + * @param string $groupName + * + * @return $this + */ + public function setGroupName($groupName) + { + $this->groupName = $groupName; + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData The data sent by the user + */ + public function createElements(array $formData) + { + // TODO(jom): Fetching already existing members to prevent the user from mistakenly creating duplicate + // memberships (no matter whether the data source permits it or not, a member does never need to be + // added more than once) should be kept at backend level (GroupController::fetchUsers) but this does + // not work currently as our ldap protocol stuff is unable to handle our filter implementation.. + $members = $this->backend + ->select() + ->from('group_membership', array('user_name')) + ->where('group_name', $this->groupName) + ->fetchColumn(); + $filter = empty($members) ? Filter::matchAll() : Filter::not(Filter::where('user_name', $members)); + + $users = $this->ds->select()->from('user', array('user_name'))->applyFilter($filter)->fetchColumn(); + if (! empty($users)) { + $this->addElement( + 'multiselect', + 'user_name', + array( + 'multiOptions' => array_combine($users, $users), + 'label' => $this->translate('Backend Users'), + 'description' => $this->translate( + 'Select one or more users (fetched from your user backends) to add as group member' + ), + 'class' => 'grant-permissions' + ) + ); + } + + $this->addElement( + 'textarea', + 'users', + array( + 'required' => empty($users), + 'label' => $this->translate('Users'), + 'description' => $this->translate( + 'Provide one or more usernames separated by comma to add as group member' + ) + ) + ); + + $this->setTitle(sprintf($this->translate('Add members for group %s'), $this->groupName)); + $this->setSubmitLabel($this->translate('Add')); + } + + /** + * Insert the members for the group + * + * @return bool + */ + public function onSuccess() + { + $userNames = $this->getValue('user_name') ?: array(); + if (($users = $this->getValue('users'))) { + $userNames = array_merge($userNames, array_map('trim', explode(',', $users))); + } + + if (empty($userNames)) { + $this->info($this->translate( + 'Please provide at least one username, either by choosing one ' + . 'in the list or by manually typing one in the text box below' + )); + return false; + } + + $single = null; + foreach ($userNames as $userName) { + try { + $this->backend->insert( + 'group_membership', + array( + 'group_name' => $this->groupName, + 'user_name' => $userName + ) + ); + } catch (NotFoundError $e) { + throw $e; // Trigger 404, the group name is initially accessed as GET parameter + } catch (Exception $e) { + Notification::error(sprintf( + $this->translate('Failed to add "%s" as group member for "%s"'), + $userName, + $this->groupName + )); + $this->error($e->getMessage()); + return false; + } + + $single = $single === null; + } + + if ($single) { + Notification::success(sprintf($this->translate('Group member "%s" added successfully'), $userName)); + } else { + Notification::success($this->translate('Group members added successfully')); + } + + return true; + } +} diff --git a/application/forms/Config/UserGroup/DbUserGroupBackendForm.php b/application/forms/Config/UserGroup/DbUserGroupBackendForm.php new file mode 100644 index 000000000..9f1915968 --- /dev/null +++ b/application/forms/Config/UserGroup/DbUserGroupBackendForm.php @@ -0,0 +1,58 @@ +setName('form_config_dbusergroupbackend'); + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + $resourceNames = $this->getDatabaseResourceNames(); + $this->addElement( + 'select', + 'resource', + array( + 'required' => true, + 'label' => $this->translate('Database Connection'), + 'description' => $this->translate('The database connection to use for this backend'), + 'multiOptions' => empty($resourceNames) ? array() : array_combine($resourceNames, $resourceNames) + ) + ); + } + + /** + * Return the names of all configured database resources + * + * @return array + */ + protected function getDatabaseResourceNames() + { + $names = array(); + foreach (ResourceFactory::getResourceConfigs() as $name => $config) { + if (strtolower($config->type) === 'db') { + $names[] = $name; + } + } + + return $names; + } +} diff --git a/application/forms/Config/UserGroup/UserGroupBackendForm.php b/application/forms/Config/UserGroup/UserGroupBackendForm.php new file mode 100644 index 000000000..dd9875c9a --- /dev/null +++ b/application/forms/Config/UserGroup/UserGroupBackendForm.php @@ -0,0 +1,198 @@ +setName('form_config_usergroupbackend'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + /** + * Return a form object for the given backend type + * + * @param string $type The backend type for which to return a form + * + * @return Form + */ + public function getBackendForm($type) + { + if ($type === 'db') { + return new DbUserGroupBackendForm(); + } else { + throw new InvalidArgumentException(sprintf($this->translate('Invalid backend type "%s" provided'), $type)); + } + } + + /** + * Populate the form with the given backend's config + * + * @param string $name + * + * @return $this + * + * @throws NotFoundError In case no backend with the given name is found + */ + public function load($name) + { + if (! $this->config->hasSection($name)) { + throw new NotFoundError('No user group backend called "%s" found', $name); + } + + $data = $this->config->getSection($name)->toArray(); + $data['type'] = $data['backend']; + $data['name'] = $name; + $this->populate($data); + return $this; + } + + /** + * Add a new user group backend + * + * @param array $data + * + * @return $this + * + * @throws InvalidArgumentException In case $data does not contain a backend name + * @throws IcingaException In case a backend with the same name already exists + */ + public function add(array $data) + { + if (! isset($data['name'])) { + throw new InvalidArgumentException('Key \'name\' missing'); + } + + $backendName = $data['name']; + if ($this->config->hasSection($backendName)) { + throw new IcingaException('A user group backend with the name "%s" does already exist', $backendName); + } + + unset($data['name']); + $this->config->setSection($backendName, $data); + return $this; + } + + /** + * Edit a user group backend + * + * @param string $name + * @param array $data + * + * @return $this + * + * @throws NotFoundError In case no backend with the given name is found + */ + public function edit($name, array $data) + { + if (! $this->config->hasSection($name)) { + throw new NotFoundError('No user group backend called "%s" found', $name); + } + + $backendConfig = $this->config->getSection($name); + if (isset($data['name']) && $data['name'] !== $name) { + $this->config->removeSection($name); + $name = $data['name']; + unset($data['name']); + } + + $this->config->setSection($name, $backendConfig->merge($data)); + return $this; + } + + /** + * Remove a user group backend + * + * @param string $name + * + * @return $this + */ + public function delete($name) + { + $this->config->removeSection($name); + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Backend Name'), + 'description' => $this->translate( + 'The name of this user group backend that is used to differentiate it from others' + ), + 'validators' => array( + array( + 'Regex', + false, + array( + 'pattern' => '/^[^\\[\\]:]+$/', + 'messages' => array( + 'regexNotMatch' => $this->translate( + 'The backend name cannot contain \'[\', \']\' or \':\'.' + ) + ) + ) + ) + ) + ) + ); + + // TODO(jom): We did not think about how to configure custom group backends yet! + $backendTypes = array( + 'db' => $this->translate('Database') + ); + + $backendType = isset($formData['type']) ? $formData['type'] : null; + if ($backendType === null) { + $backendType = key($backendTypes); + } + + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, // Prevents the element from being submitted, see #7717 + 'value' => $backendType + ) + ); + + $this->addElement( + 'select', + 'type', + array( + 'ignore' => true, + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Backend Type'), + 'description' => $this->translate('The type of this user group backend'), + 'multiOptions' => $backendTypes + ) + ); + + $backendForm = $this->getBackendForm($backendType); + $backendForm->createElements($formData); + $this->addElements($backendForm->getElements()); + } +} diff --git a/application/forms/Config/UserGroup/UserGroupForm.php b/application/forms/Config/UserGroup/UserGroupForm.php new file mode 100644 index 000000000..598029c1d --- /dev/null +++ b/application/forms/Config/UserGroup/UserGroupForm.php @@ -0,0 +1,126 @@ +addElement( + 'text', + 'group_name', + array( + 'required' => true, + 'label' => $this->translate('Group Name') + ) + ); + + if ($this->shouldInsert()) { + $this->setTitle($this->translate('Add a new group')); + $this->setSubmitLabel($this->translate('Add')); + } else { // $this->shouldUpdate() + $this->setTitle(sprintf($this->translate('Edit group %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + } + + /** + * Update a group + * + * @return bool + */ + protected function onUpdateSuccess() + { + if (parent::onUpdateSuccess()) { + if (($newName = $this->getValue('group_name')) !== $this->getIdentifier()) { + $this->getRedirectUrl()->setParam('group', $newName); + } + + return true; + } + + return false; + } + + /** + * Create and add elements to this form to delete a group + * + * @param array $formData The data sent by the user + */ + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove group %s?'), $this->getIdentifier())); + $this->addDescription($this->translate( + 'Note that all users that are currently a member of this group will' + . ' have their membership cleared automatically.' + )); + $this->setSubmitLabel($this->translate('Yes')); + } + + /** + * Create and return a filter to use when updating or deleting a group + * + * @return Filter + */ + protected function createFilter() + { + return Filter::where('group_name', $this->getIdentifier()); + } + + /** + * Return a notification message to use when inserting a group + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getInsertMessage($success) + { + if ($success) { + return $this->translate('Group added successfully'); + } else { + return $this->translate('Failed to add group'); + } + } + + /** + * Return a notification message to use when updating a group + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getUpdateMessage($success) + { + if ($success) { + return sprintf($this->translate('Group "%s" has been edited'), $this->getIdentifier()); + } else { + return sprintf($this->translate('Failed to edit group "%s"'), $this->getIdentifier()); + } + } + + /** + * Return a notification message to use when deleting a group + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + protected function getDeleteMessage($success) + { + if ($success) { + return sprintf($this->translate('Group "%s" has been removed'), $this->getIdentifier()); + } else { + return sprintf($this->translate('Failed to remove group "%s"'), $this->getIdentifier()); + } + } +} diff --git a/application/forms/RepositoryForm.php b/application/forms/RepositoryForm.php new file mode 100644 index 000000000..136855d1e --- /dev/null +++ b/application/forms/RepositoryForm.php @@ -0,0 +1,389 @@ +repository = $repository; + return $this; + } + + /** + * Return the name of the entry to handle + * + * @return string + */ + protected function getIdentifier() + { + return $this->identifier; + } + + /** + * Return the current data of the entry being handled + * + * @return array + */ + protected function getData() + { + return $this->data; + } + + /** + * Return whether an entry should be inserted + * + * @return bool + */ + public function shouldInsert() + { + return $this->mode === self::MODE_INSERT; + } + + /** + * Return whether an entry should be udpated + * + * @return bool + */ + public function shouldUpdate() + { + return $this->mode === self::MODE_UPDATE; + } + + /** + * Return whether an entry should be deleted + * + * @return bool + */ + public function shouldDelete() + { + return $this->mode === self::MODE_DELETE; + } + + /** + * Add a new entry + * + * @param array $data The defaults to use, if any + * + * @return $this + */ + public function add(array $data = array()) + { + $this->mode = static::MODE_INSERT; + $this->data = $data; + return $this; + } + + /** + * Edit an entry + * + * @param string $name The entry's name + * @param array $data The entry's current data + * + * @return $this + */ + public function edit($name, array $data = array()) + { + $this->mode = static::MODE_UPDATE; + $this->identifier = $name; + $this->data = $data; + return $this; + } + + /** + * Remove an entry + * + * @param string $name The entry's name + * + * @return $this + */ + public function remove($name) + { + $this->mode = static::MODE_DELETE; + $this->identifier = $name; + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData The data sent by the user + */ + public function createElements(array $formData) + { + if ($this->shouldInsert()) { + $this->createInsertElements($formData); + } elseif ($this->shouldUpdate()) { + $this->createUpdateElements($formData); + } elseif ($this->shouldDelete()) { + $this->createDeleteElements($formData); + } + } + + /** + * Prepare the form for the requested mode + */ + public function onRequest() + { + if ($this->shouldInsert()) { + $this->onInsertRequest(); + } elseif ($this->shouldUpdate()) { + $this->onUpdateRequest(); + } elseif ($this->shouldDelete()) { + $this->onDeleteRequest(); + } + } + + /** + * Prepare the form for mode insert + * + * Populates the form with the data passed to add(). + */ + protected function onInsertRequest() + { + $data = $this->getData(); + if (! empty($data)) { + $this->populate($data); + } + } + + /** + * Prepare the form for mode update + * + * Populates the form with either the data passed to edit() or tries to fetch it from the repository. + * + * @throws NotFoundError In case the entry to update cannot be found + */ + protected function onUpdateRequest() + { + $data = $this->getData(); + if (empty($data)) { + $row = $this->repository->select()->applyFilter($this->createFilter())->fetchRow(); + if ($row === false) { + throw new NotFoundError('Entry "%s" not found', $this->getIdentifier()); + } + + $data = get_object_vars($row); + } + + $this->populate($data); + } + + /** + * Prepare the form for mode delete + * + * Verifies that the repository contains the entry to delete. + * + * @throws NotFoundError In case the entry to delete cannot be found + */ + protected function onDeleteRequest() + { + if ($this->repository->select()->addFilter($this->createFilter())->count() === 0) { + throw new NotFoundError('Entry "%s" not found', $this->getIdentifier()); + } + } + + /** + * Apply the requested mode on the repository + * + * @return bool + */ + public function onSuccess() + { + if ($this->shouldInsert()) { + return $this->onInsertSuccess(); + } elseif ($this->shouldUpdate()) { + return $this->onUpdateSuccess(); + } elseif ($this->shouldDelete()) { + return $this->onDeleteSuccess(); + } + } + + /** + * Apply mode insert on the repository + * + * @return bool + */ + protected function onInsertSuccess() + { + try { + $this->repository->insert( + $this->repository->getBaseTable(), + $this->getValues() + ); + } catch (Exception $e) { + Notification::error($this->getInsertMessage(false)); + $this->error($e->getMessage()); + return false; + } + + Notification::success($this->getInsertMessage(true)); + return true; + } + + /** + * Apply mode update on the repository + * + * @return bool + */ + protected function onUpdateSuccess() + { + try { + $this->repository->update( + $this->repository->getBaseTable(), + $this->getValues(), + $this->createFilter() + ); + } catch (Exception $e) { + Notification::error($this->getUpdateMessage(false)); + $this->error($e->getMessage()); + return false; + } + + Notification::success($this->getUpdateMessage(true)); + return true; + } + + /** + * Apply mode delete on the repository + * + * @return bool + */ + protected function onDeleteSuccess() + { + try { + $this->repository->delete( + $this->repository->getBaseTable(), + $this->createFilter() + ); + } catch (Exception $e) { + Notification::error($this->getDeleteMessage(false)); + $this->error($e->getMessage()); + return false; + } + + Notification::success($this->getDeleteMessage(true)); + return true; + } + + /** + * Create and add elements to this form to insert an entry + * + * @param array $formData The data sent by the user + */ + abstract protected function createInsertElements(array $formData); + + /** + * Create and add elements to this form to update an entry + * + * Calls createInsertElements() by default. Overwrite this to add different elements when in mode update. + * + * @param array $formData The data sent by the user + */ + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + } + + /** + * Create and add elements to this form to delete an entry + * + * @param array $formData The data sent by the user + */ + abstract protected function createDeleteElements(array $formData); + + /** + * Create and return a filter to use when selecting, updating or deleting an entry + * + * @return Filter + */ + abstract protected function createFilter(); + + /** + * Return a notification message to use when inserting an entry + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + abstract protected function getInsertMessage($success); + + /** + * Return a notification message to use when updating an entry + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + abstract protected function getUpdateMessage($success); + + /** + * Return a notification message to use when deleting an entry + * + * @param bool $success true or false, whether the operation was successful + * + * @return string + */ + abstract protected function getDeleteMessage($success); +} diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 07b09ce17..ee5d312f0 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -21,14 +21,30 @@ class RoleForm extends ConfigForm * @var array */ protected $providedPermissions = array( - '*' => '*', - 'config/*' => 'config/*', - 'config/application/*' => 'config/application/*', - 'config/application/general' => 'config/application/general', - 'config/application/authentication' => 'config/application/authentication', - 'config/application/resources' => 'config/application/resources', - 'config/application/roles' => 'config/application/roles', - 'config/modules' => 'config/modules' + '*' => '*', + 'config/*' => 'config/*', + 'config/application/*' => 'config/application/*', + 'config/application/general' => 'config/application/general', + 'config/application/resources' => 'config/application/resources', + 'config/application/userbackend' => 'config/application/userbackend', + 'config/application/usergroupbackend' => 'config/application/usergroupbackend', + 'config/authentication/*' => 'config/authentication/*', + 'config/authentication/users/*' => 'config/authentication/users/*', + 'config/authentication/users/show' => 'config/authentication/users/show', + 'config/authentication/users/add' => 'config/authentication/users/add', + 'config/authentication/users/edit' => 'config/authentication/users/edit', + 'config/authentication/users/remove' => 'config/authentication/users/remove', + 'config/authentication/groups/*' => 'config/authentication/groups/*', + 'config/authentication/groups/show' => 'config/authentication/groups/show', + 'config/authentication/groups/add' => 'config/authentication/groups/add', + 'config/authentication/groups/edit' => 'config/authentication/groups/edit', + 'config/authentication/groups/remove' => 'config/authentication/groups/remove', + 'config/authentication/roles/*' => 'config/authentication/roles/*', + 'config/authentication/roles/show' => 'config/authentication/roles/show', + 'config/authentication/roles/add' => 'config/authentication/roles/add', + 'config/authentication/roles/edit' => 'config/authentication/roles/edit', + 'config/authentication/roles/remove' => 'config/authentication/roles/remove', + 'config/modules' => 'config/modules' ); /** diff --git a/application/views/scripts/config/application.phtml b/application/views/scripts/config/general.phtml similarity index 100% rename from application/views/scripts/config/application.phtml rename to application/views/scripts/config/general.phtml diff --git a/application/views/scripts/config/authentication/create.phtml b/application/views/scripts/config/userbackend/create.phtml similarity index 100% rename from application/views/scripts/config/authentication/create.phtml rename to application/views/scripts/config/userbackend/create.phtml diff --git a/application/views/scripts/config/authentication/modify.phtml b/application/views/scripts/config/userbackend/modify.phtml similarity index 100% rename from application/views/scripts/config/authentication/modify.phtml rename to application/views/scripts/config/userbackend/modify.phtml diff --git a/application/views/scripts/config/authentication/remove.phtml b/application/views/scripts/config/userbackend/remove.phtml similarity index 100% rename from application/views/scripts/config/authentication/remove.phtml rename to application/views/scripts/config/userbackend/remove.phtml diff --git a/application/views/scripts/config/authentication/reorder.phtml b/application/views/scripts/config/userbackend/reorder.phtml similarity index 71% rename from application/views/scripts/config/authentication/reorder.phtml rename to application/views/scripts/config/userbackend/reorder.phtml index e4b72d7e1..64a6fc594 100644 --- a/application/views/scripts/config/authentication/reorder.phtml +++ b/application/views/scripts/config/userbackend/reorder.phtml @@ -2,8 +2,8 @@
- - icon('plus'); ?>translate('Create A New Authentication Backend'); ?> + + icon('plus'); ?>translate('Create A New User Backend'); ?>
diff --git a/application/views/scripts/form/reorder-authbackend.phtml b/application/views/scripts/form/reorder-authbackend.phtml index cd8001436..20d4e3696 100644 --- a/application/views/scripts/form/reorder-authbackend.phtml +++ b/application/views/scripts/form/reorder-authbackend.phtml @@ -12,22 +12,22 @@ qlink( $backendNames[$i], - 'config/editAuthenticationBackend', - array('auth_backend' => $backendNames[$i]), + 'config/edituserbackend', + array('backend' => $backendNames[$i]), array( 'icon' => 'edit', - 'title' => sprintf($this->translate('Edit authentication backend %s'), $backendNames[$i]) + 'title' => sprintf($this->translate('Edit user backend %s'), $backendNames[$i]) ) ); ?> qlink( '', - 'config/removeAuthenticationBackend', - array('auth_backend' => $backendNames[$i]), + 'config/removeuserbackend', + array('backend' => $backendNames[$i]), array( 'icon' => 'trash', - 'title' => sprintf($this->translate('Remove authentication backend %s'), $backendNames[$i]) + 'title' => sprintf($this->translate('Remove user backend %s'), $backendNames[$i]) ) ); ?> @@ -40,7 +40,7 @@ ); ?>" title="translate( 'Move up in authentication order' ); ?>" aria-label="translate('Move authentication backend %s upwards'), + $this->translate('Move user backend %s upwards'), $backendNames[$i] ); ?>"> icon('up-big'); ?> @@ -54,7 +54,7 @@ ); ?>" title="translate( 'Move down in authentication order' ); ?>" aria-label="translate('Move authentication backend %s downwards'), + $this->translate('Move user backend %s downwards'), $backendNames[$i] ); ?>"> icon('down-big'); ?> diff --git a/application/views/scripts/roles/new.phtml b/application/views/scripts/group/form.phtml similarity index 50% rename from application/views/scripts/roles/new.phtml rename to application/views/scripts/group/form.phtml index ca1e1559e..cbf06590d 100644 --- a/application/views/scripts/roles/new.phtml +++ b/application/views/scripts/group/form.phtml @@ -1,6 +1,6 @@
- showOnlyCloseButton() ?> + showOnlyCloseButton(); ?>
- +
\ No newline at end of file diff --git a/application/views/scripts/group/list.phtml b/application/views/scripts/group/list.phtml new file mode 100644 index 000000000..bcd9dca93 --- /dev/null +++ b/application/views/scripts/group/list.phtml @@ -0,0 +1,78 @@ +compact): ?> +
+ tabs; ?> + sortBox; ?> + limiter; ?> + paginator; ?> +
+ backendSelection; ?> + filterEditor; ?> +
+
+ +
+translate('No backend found which is able to list groups') . '
'; + return; +} else { + $extensible = $this->hasPermission('config/authentication/groups/add') && $backend instanceof Extensible; + $reducible = $this->hasPermission('config/authentication/groups/remove') && $backend instanceof Reducible; +} + +if (count($groups) > 0): ?> + + + + + + + + + + + + + + + + + + + +
translate('Group'); ?>translate('Remove'); ?>
qlink($group->group_name, 'group/show', array( + 'backend' => $backend->getName(), + 'group' => $group->group_name + ), array( + 'title' => sprintf($this->translate('Show detailed information for group %s'), $group->group_name) + )); ?> + qlink( + null, + 'group/remove', + array( + 'backend' => $backend->getName(), + 'group' => $group->group_name + ), + array( + 'title' => sprintf($this->translate('Remove group %s'), $group->group_name), + 'icon' => 'trash' + ) + ); ?> +
+ +

translate('No groups found matching the filter'); ?>

+ + +qlink($this->translate('Add a new group'), 'group/add', array('backend' => $backend->getName()), array( + 'icon' => 'plus', + 'data-base-target' => '_next', + 'class' => 'group-add' +)); ?> + +
\ No newline at end of file diff --git a/application/views/scripts/group/show.phtml b/application/views/scripts/group/show.phtml new file mode 100644 index 000000000..636a449d4 --- /dev/null +++ b/application/views/scripts/group/show.phtml @@ -0,0 +1,81 @@ +hasPermission('config/authentication/groups/add') && $backend instanceof Extensible; + +$editLink = null; +if ($this->hasPermission('config/authentication/groups/edit') && $backend instanceof Updatable) { + $editLink = $this->qlink( + null, + 'group/edit', + array( + 'backend' => $backend->getName(), + 'group' => $group->group_name + ), + array( + 'title' => sprintf($this->translate('Edit group %s'), $group->group_name), + 'class' => 'group-edit', + 'icon' => 'edit' + ) + ); +} + +?> +
+ compact): ?> + + +
+

escape($group->group_name); ?>

+

translate('Created at'); ?>: created_at === null ? '-' : $this->formatDateTime($group->created_at); ?>

+

translate('Last modified'); ?>: last_modified === null ? '-' : $this->formatDateTime($group->last_modified); ?>

+
+ compact): ?> + sortBox; ?> + + limiter; ?> + paginator; ?> + compact): ?> + filterEditor; ?> + +
+
+ 0): ?> + + + + + + + + + + + + + + + + + + + +
translate('Username'); ?>translate('Remove'); ?>
escape($member->user_name); ?> + getElement('user_name')->setValue($member->user_name); echo $removeForm; ?> +
+ +

translate('No group member found matching the filter'); ?>

+ + + qlink($this->translate('Add a new member'), 'group/addmember', array( + 'backend' => $backend->getName(), + 'group' => $group->group_name + ), array( + 'icon' => 'plus', + 'data-base-target' => '_next', + 'class' => 'member-add' + )); ?> + +
\ No newline at end of file diff --git a/application/views/scripts/roles/remove.phtml b/application/views/scripts/role/form.phtml similarity index 50% rename from application/views/scripts/roles/remove.phtml rename to application/views/scripts/role/form.phtml index ca1e1559e..cbf06590d 100644 --- a/application/views/scripts/roles/remove.phtml +++ b/application/views/scripts/role/form.phtml @@ -1,6 +1,6 @@
- showOnlyCloseButton() ?> + showOnlyCloseButton(); ?>
- +
\ No newline at end of file diff --git a/application/views/scripts/roles/index.phtml b/application/views/scripts/role/list.phtml similarity index 95% rename from application/views/scripts/roles/index.phtml rename to application/views/scripts/role/list.phtml index 17e947249..766ba26f3 100644 --- a/application/views/scripts/roles/index.phtml +++ b/application/views/scripts/role/list.phtml @@ -22,7 +22,7 @@ qlink( $name, - 'roles/update', + 'role/edit', array('role' => $name), array('title' => sprintf($this->translate('Edit role %s'), $name)) ); ?> @@ -54,7 +54,7 @@ qlink( '', - 'roles/remove', + 'role/remove', array('role' => $name), array( 'icon' => 'trash', @@ -67,7 +67,7 @@ - + translate('Create a New Role') ?>
diff --git a/application/views/scripts/roles/update.phtml b/application/views/scripts/user/form.phtml similarity index 50% rename from application/views/scripts/roles/update.phtml rename to application/views/scripts/user/form.phtml index ca1e1559e..cbf06590d 100644 --- a/application/views/scripts/roles/update.phtml +++ b/application/views/scripts/user/form.phtml @@ -1,6 +1,6 @@
- showOnlyCloseButton() ?> + showOnlyCloseButton(); ?>
- +
\ No newline at end of file diff --git a/application/views/scripts/user/list.phtml b/application/views/scripts/user/list.phtml new file mode 100644 index 000000000..76a6f2b8b --- /dev/null +++ b/application/views/scripts/user/list.phtml @@ -0,0 +1,78 @@ +compact): ?> +
+ tabs; ?> + sortBox; ?> + limiter; ?> + paginator; ?> +
+ backendSelection; ?> + filterEditor; ?> +
+
+ +
+translate('No backend found which is able to list users') . '
'; + return; +} else { + $extensible = $this->hasPermission('config/authentication/users/add') && $backend instanceof Extensible; + $reducible = $this->hasPermission('config/authentication/users/remove') && $backend instanceof Reducible; +} + +if (count($users) > 0): ?> + + + + + + + + + + + + + + + + + + + +
translate('Username'); ?>translate('Remove'); ?>
qlink($user->user_name, 'user/show', array( + 'backend' => $backend->getName(), + 'user' => $user->user_name + ), array( + 'title' => sprintf($this->translate('Show detailed information about %s'), $user->user_name) + )); ?> + qlink( + null, + 'user/remove', + array( + 'backend' => $backend->getName(), + 'user' => $user->user_name + ), + array( + 'title' => sprintf($this->translate('Remove user %s'), $user->user_name), + 'icon' => 'trash' + ) + ); ?> +
+ +

translate('No users found matching the filter'); ?>

+ + +qlink($this->translate('Add a new user'), 'user/add', array('backend' => $backend->getName()), array( + 'icon' => 'plus', + 'data-base-target' => '_next', + 'class' => 'user-add' +)); ?> + + \ No newline at end of file diff --git a/application/views/scripts/user/show.phtml b/application/views/scripts/user/show.phtml new file mode 100644 index 000000000..82f4c53f9 --- /dev/null +++ b/application/views/scripts/user/show.phtml @@ -0,0 +1,95 @@ +hasPermission('config/authentication/users/edit') && $backend instanceof Updatable) { + $editLink = $this->qlink( + null, + 'user/edit', + array( + 'backend' => $backend->getName(), + 'user' => $user->user_name + ), + array( + 'title' => sprintf($this->translate('Edit user %s'), $user->user_name), + 'class' => 'user-edit', + 'icon' => 'edit' + ) + ); +} + +?> +
+ compact): ?> + + +
+

escape($user->user_name); ?>

+

translate('State'); ?>: is_active === null ? '-' : ($user->is_active ? $this->translate('Active') : $this->translate('Inactive')); ?>

+

translate('Created at'); ?>: created_at === null ? '-' : $this->formatDateTime($user->created_at); ?>

+

translate('Last modified'); ?>: last_modified === null ? '-' : $this->formatDateTime($user->last_modified); ?>

+
+ compact): ?> + sortBox; ?> + + limiter; ?> + paginator; ?> + compact): ?> + filterEditor; ?> + +
+
+ 0): ?> + + + + + + + + + + + + + + + +
translate('Group'); ?>translate('Cancel', 'group.membership'); ?>
+ hasPermission('config/authentication/groups/show') && $membership->backend instanceof Selectable): ?> + qlink($membership->group_name, 'group/show', array( + 'backend' => $membership->backend->getName(), + 'group' => $membership->group_name + ), array( + 'title' => sprintf($this->translate('Show detailed information for group %s'), $membership->group_name) + )); ?> + + escape($membership->group_name); ?> + + + backend instanceof Reducible): ?> + setAction($this->url('group/removemember', array( + 'backend' => $membership->backend->getName(), + 'group' => $membership->group_name + ))); ?> + + - + +
+ +

translate('No memberships found matching the filter'); ?>

+ + +qlink($this->translate('Create new membership'), 'user/createmembership', array( + 'backend' => $backend->getName(), + 'user' => $user->user_name +), array( + 'icon' => 'plus', + 'data-base-target' => '_next', + 'class' => 'membership-create' +)); ?> + +
\ No newline at end of file diff --git a/application/views/scripts/usergroupbackend/form.phtml b/application/views/scripts/usergroupbackend/form.phtml new file mode 100644 index 000000000..cbf06590d --- /dev/null +++ b/application/views/scripts/usergroupbackend/form.phtml @@ -0,0 +1,6 @@ +
+ showOnlyCloseButton(); ?> +
+
+ +
\ No newline at end of file diff --git a/application/views/scripts/usergroupbackend/list.phtml b/application/views/scripts/usergroupbackend/list.phtml new file mode 100644 index 000000000..58aa2deba --- /dev/null +++ b/application/views/scripts/usergroupbackend/list.phtml @@ -0,0 +1,46 @@ +
+ +
+
+qlink( + $this->translate('Create A New User Group Backend'), + 'usergroupbackend/create', + null, + array( + 'icon' => 'plus' + ) +); ?> + 0): ?> + + + + + + + + + + + + + + + +
translate('Backend'); ?>translate('Remove'); ?>
+ qlink( + $backendName, + 'usergroupbackend/edit', + array('backend' => $backendName), + array('title' => sprintf($this->translate('Edit user group backend %s'), $backendName)) + ); ?> + qlink( + null, + 'usergroupbackend/remove', + array('backend' => $backendName), + array( + 'title' => sprintf($this->translate('Remove user group backend %s'), $backendName), + 'icon' => 'trash' + ) + ); ?>
+ +
\ No newline at end of file diff --git a/etc/schema/mysql.schema.sql b/etc/schema/mysql.schema.sql index 16ba9f4ff..5f22aead3 100644 --- a/etc/schema/mysql.schema.sql +++ b/etc/schema/mysql.schema.sql @@ -1,21 +1,25 @@ # Icinga Web 2 | (c) 2013-2015 Icinga Development Team | GPLv2+ CREATE TABLE `icingaweb_group`( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(64) COLLATE utf8_unicode_ci NOT NULL, - `parent` varchar(64) COLLATE utf8_unicode_ci NULL DEFAULT NULL, + `parent` int(10) unsigned NULL DEFAULT NULL, `ctime` timestamp NULL DEFAULT NULL, `mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`name`) + PRIMARY KEY (`id`), + UNIQUE KEY `idx_name` (`name`), + CONSTRAINT `fk_icingaweb_group_parent_id` FOREIGN KEY (`parent`) + REFERENCES `icingaweb_group` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `icingaweb_group_membership`( - `group_name` varchar(64) COLLATE utf8_unicode_ci NOT NULL, + `group_id` int(10) unsigned NOT NULL, `username` varchar(64) COLLATE utf8_unicode_ci NOT NULL, `ctime` timestamp NULL DEFAULT NULL, `mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`group_name`,`username`), - CONSTRAINT `fk_icingaweb_group_membership_icingaweb_group` FOREIGN KEY (`group_name`) - REFERENCES `icingaweb_group` (`name`) + PRIMARY KEY (`group_id`,`username`), + CONSTRAINT `fk_icingaweb_group_membership_icingaweb_group` FOREIGN KEY (`group_id`) + REFERENCES `icingaweb_group` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `icingaweb_user`( diff --git a/etc/schema/pgsql.schema.sql b/etc/schema/pgsql.schema.sql index 034d8288a..56117d4f8 100644 --- a/etc/schema/pgsql.schema.sql +++ b/etc/schema/pgsql.schema.sql @@ -1,8 +1,13 @@ /* Icinga Web 2 | (c) 2013-2015 Icinga Development Team | GPLv2+ */ +CREATE OR REPLACE FUNCTION unix_timestamp(timestamp with time zone) RETURNS bigint AS ' + SELECT EXTRACT(EPOCH FROM $1)::bigint AS result +' LANGUAGE sql; + CREATE TABLE "icingaweb_group" ( + "id" serial, "name" character varying(64) NOT NULL, - "parent" character varying(64) NULL DEFAULT NULL, + "parent" int NULL DEFAULT NULL, "ctime" timestamp NULL DEFAULT NULL, "mtime" timestamp NULL DEFAULT NULL ); @@ -10,7 +15,7 @@ CREATE TABLE "icingaweb_group" ( ALTER TABLE ONLY "icingaweb_group" ADD CONSTRAINT pk_icingaweb_group PRIMARY KEY ( - "name" + "id" ); CREATE UNIQUE INDEX idx_icingaweb_group @@ -19,8 +24,17 @@ CREATE UNIQUE INDEX idx_icingaweb_group lower((name)::text) ); +ALTER TABLE ONLY "icingaweb_group" + ADD CONSTRAINT fk_icingaweb_group_parent_id + FOREIGN KEY ( + "parent" + ) + REFERENCES "icingaweb_group" ( + "id" +); + CREATE TABLE "icingaweb_group_membership" ( - "group_name" character varying(64) NOT NULL, + "group_id" int NOT NULL, "username" character varying(64) NOT NULL, "ctime" timestamp NULL DEFAULT NULL, "mtime" timestamp NULL DEFAULT NULL @@ -28,15 +42,17 @@ CREATE TABLE "icingaweb_group_membership" ( ALTER TABLE ONLY "icingaweb_group_membership" ADD CONSTRAINT pk_icingaweb_group_membership - PRIMARY KEY ( - "group_name", - "username" + FOREIGN KEY ( + "group_id" + ) + REFERENCES "icingaweb_group" ( + "id" ); CREATE UNIQUE INDEX idx_icingaweb_group_membership ON "icingaweb_group_membership" USING btree ( - lower((group_name)::text), + group_id, lower((username)::text) ); diff --git a/library/Icinga/Application/Config.php b/library/Icinga/Application/Config.php index 8a1746b69..02851b975 100644 --- a/library/Icinga/Application/Config.php +++ b/library/Icinga/Application/Config.php @@ -9,13 +9,15 @@ use LogicException; use UnexpectedValueException; use Icinga\Util\File; use Icinga\Data\ConfigObject; +use Icinga\Data\Selectable; +use Icinga\Data\SimpleQuery; use Icinga\File\Ini\IniWriter; use Icinga\Exception\NotReadableError; /** * Container for INI like configuration and global registry of application and module related configuration. */ -class Config implements Countable, Iterator +class Config implements Countable, Iterator, Selectable { /** * Configuration directory where ALL (application and module) configuration is located @@ -85,6 +87,26 @@ class Config implements Countable, Iterator return $this; } + /** + * Return the internal ConfigObject + * + * @return ConfigObject + */ + public function getConfigObject() + { + return $this->config; + } + + /** + * Provide a query for the internal config object + * + * @return SimpleQuery + */ + public function select() + { + return $this->config->select(); + } + /** * Return the count of available sections * @@ -92,7 +114,7 @@ class Config implements Countable, Iterator */ public function count() { - return $this->config->count(); + return $this->select()->count(); } /** diff --git a/library/Icinga/Application/Logger.php b/library/Icinga/Application/Logger.php index 925695dad..26241b3d5 100644 --- a/library/Icinga/Application/Logger.php +++ b/library/Icinga/Application/Logger.php @@ -243,7 +243,9 @@ class Logger return vsprintf( array_shift($arguments), array_map( - function ($a) { return is_string($a) ? $a : json_encode($a); }, + function ($a) { + return is_string($a) ? $a : ($a instanceof Exception ? $a->getMessage() : json_encode($a)); + }, $arguments ) ); diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index e8481aab8..54e23d02c 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -179,10 +179,26 @@ class Module protected $paneItems = array(); /** + * A set of objects representing a searchUrl configuration + * * @var array */ protected $searchUrls = array(); + /** + * This module's user backends providing several authentication mechanisms + * + * @var array + */ + protected $userBackends = array(); + + /** + * This module's user group backends + * + * @var array + */ + protected $userGroupBackends = array(); + /** * Provide a search URL * @@ -201,6 +217,11 @@ class Module $this->searchUrls[] = $searchUrl; } + /** + * Return this module's search urls + * + * @return array + */ public function getSearchUrls() { $this->launchConfigScript(); @@ -702,6 +723,28 @@ class Module return new $this->setupWizard; } + /** + * Return this module's user backends + * + * @return array + */ + public function getUserBackends() + { + $this->launchConfigScript(); + return $this->userBackends; + } + + /** + * Return this module's user group backends + * + * @return array + */ + public function getUserGroupBackends() + { + $this->launchConfigScript(); + return $this->userGroupBackends; + } + /** * Provide a named permission * @@ -777,6 +820,34 @@ class Module return $this; } + /** + * Provide a user backend capable of authenticating users + * + * @param string $identifier The identifier of the new backend type + * @param string $className The name of the class + * + * @return $this + */ + protected function provideUserBackend($identifier, $className) + { + $this->userBackends[strtolower($identifier)] = $className; + return $this; + } + + /** + * Provide a user group backend + * + * @param string $identifier The identifier of the new backend type + * @param string $className The name of the class + * + * @return $this + */ + protected function provideUserGroupBackend($identifier, $className) + { + $this->userGroupBackends[strtolower($identifier)] = $className; + return $this; + } + /** * Register new namespaces on the autoloader * diff --git a/library/Icinga/Authentication/AuthChain.php b/library/Icinga/Authentication/AuthChain.php index 9b9f873c5..7354825a2 100644 --- a/library/Icinga/Authentication/AuthChain.php +++ b/library/Icinga/Authentication/AuthChain.php @@ -5,6 +5,8 @@ namespace Icinga\Authentication; use Iterator; use Icinga\Data\ConfigObject; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\UserBackendInterface; use Icinga\Application\Config; use Icinga\Application\Logger; use Icinga\Exception\ConfigurationError; @@ -24,7 +26,7 @@ class AuthChain implements Iterator /** * The consecutive user backend while looping * - * @var UserBackend + * @var UserBackendInterface */ private $currentBackend; @@ -52,7 +54,7 @@ class AuthChain implements Iterator /** * Return the current user backend * - * @return UserBackend + * @return UserBackendInterface */ public function current() { diff --git a/library/Icinga/Authentication/Backend/DbUserBackend.php b/library/Icinga/Authentication/Backend/DbUserBackend.php deleted file mode 100644 index 12fa0392f..000000000 --- a/library/Icinga/Authentication/Backend/DbUserBackend.php +++ /dev/null @@ -1,206 +0,0 @@ -conn = $conn; - } - - /** - * Test whether the given user exists - * - * @param User $user - * - * @return bool - */ - public function hasUser(User $user) - { - $select = new Zend_Db_Select($this->conn->getDbAdapter()); - $row = $select->from('icingaweb_user', array(new Zend_Db_Expr(1))) - ->where('name = ?', $user->getUsername()) - ->query()->fetchObject(); - - return ($row !== false) ? true : false; - } - - /** - * Add a new user - * - * @param string $username The name of the new user - * @param string $password The new user's password - * @param bool $active Whether the user is active - */ - public function addUser($username, $password, $active = true) - { - $passwordHash = $this->hashPassword($password); - - $stmt = $this->conn->getDbAdapter()->prepare( - 'INSERT INTO icingaweb_user VALUES (:name, :active, :password_hash, now(), DEFAULT);' - ); - $stmt->bindParam(':name', $username, PDO::PARAM_STR); - $stmt->bindParam(':active', $active, PDO::PARAM_INT); - $stmt->bindParam(':password_hash', $passwordHash, PDO::PARAM_LOB); - $stmt->execute(); - } - - /** - * Fetch the hashed password for the given user - * - * @param string $username The name of the user - * - * @return string - */ - protected function getPasswordHash($username) - { - if ($this->conn->getDbType() === 'pgsql') { - // Since PostgreSQL version 9.0 the default value for bytea_output is 'hex' instead of 'escape' - $stmt = $this->conn->getDbAdapter()->prepare( - 'SELECT ENCODE(password_hash, \'escape\') FROM icingaweb_user WHERE name = :name AND active = 1' - ); - } else { - $stmt = $this->conn->getDbAdapter()->prepare( - 'SELECT password_hash FROM icingaweb_user WHERE name = :name AND active = 1' - ); - } - - $stmt->execute(array(':name' => $username)); - $stmt->bindColumn(1, $lob, PDO::PARAM_LOB); - $stmt->fetch(PDO::FETCH_BOUND); - if (is_resource($lob)) { - $lob = stream_get_contents($lob); - } - - return $this->conn->getDbType() === 'pgsql' ? pg_unescape_bytea($lob) : $lob; - } - - /** - * Authenticate the given user and return true on success, false on failure and throw an exception on error - * - * @param User $user - * @param string $password - * - * @return bool - * - * @throws AuthenticationException - */ - public function authenticate(User $user, $password) - { - try { - $passwordHash = $this->getPasswordHash($user->getUsername()); - $passwordSalt = $this->getSalt($passwordHash); - $hashToCompare = $this->hashPassword($password, $passwordSalt); - return $hashToCompare === $passwordHash; - } catch (Exception $e) { - throw new AuthenticationException( - 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', - $user->getUsername(), - $this->getName(), - $e - ); - } - } - - /** - * Extract salt from the given password hash - * - * @param string $hash The hashed password - * - * @return string - */ - protected function getSalt($hash) - { - return substr($hash, strlen(self::HASH_ALGORITHM), self::SALT_LENGTH); - } - - /** - * Return a random salt - * - * The returned salt is safe to be used for hashing a user's password - * - * @return string - */ - protected function generateSalt() - { - return openssl_random_pseudo_bytes(self::SALT_LENGTH); - } - - /** - * Hash a password - * - * @param string $password - * @param string $salt - * - * @return string - */ - protected function hashPassword($password, $salt = null) - { - return crypt($password, self::HASH_ALGORITHM . ($salt !== null ? $salt : $this->generateSalt())); - } - - /** - * Get the number of users available - * - * @return int - */ - public function count() - { - $select = new Zend_Db_Select($this->conn->getDbAdapter()); - $row = $select->from( - 'icingaweb_user', - array('count' => 'COUNT(*)') - )->query()->fetchObject(); - - return ($row !== false) ? $row->count : 0; - } - - /** - * Return the names of all available users - * - * @return array - */ - public function listUsers() - { - $query = $this->conn->select()->from('icingaweb_user', array('name')); - - $users = array(); - foreach ($query->fetchAll() as $row) { - $users[] = $row->name; - } - - return $users; - } -} diff --git a/library/Icinga/Authentication/Backend/DbUserGroupBackend.php b/library/Icinga/Authentication/Backend/DbUserGroupBackend.php deleted file mode 100644 index d2230bf04..000000000 --- a/library/Icinga/Authentication/Backend/DbUserGroupBackend.php +++ /dev/null @@ -1,62 +0,0 @@ -conn = $conn; - } - - /** - * (non-PHPDoc) - * @see UserGroupBackend::getMemberships() For the method documentation. - */ - public function getMemberships(User $user) - { - $groups = array(); - $groupsStmt = $this->conn->getDbAdapter() - ->select() - ->from($this->conn->getTablePrefix() . 'group', array('name', 'parent')) - ->query(); - foreach ($groupsStmt as $group) { - $groups[$group->name] = $group->parent; - } - $memberships = array(); - $membershipsStmt = $this->conn->getDbAdapter() - ->select() - ->from($this->conn->getTablePrefix() . 'group_membership', array('group_name')) - ->where('username = ?', $user->getUsername()) - ->query(); - foreach ($membershipsStmt as $membership) { - $memberships[] = $membership->group_name; - $parent = $groups[$membership->group_name]; - while (isset($parent)) { - $memberships[] = $parent; - $parent = $groups[$parent]; - } - } - return $memberships; - } -} diff --git a/library/Icinga/Authentication/Backend/IniUserGroupBackend.php b/library/Icinga/Authentication/Backend/IniUserGroupBackend.php deleted file mode 100644 index b7f366511..000000000 --- a/library/Icinga/Authentication/Backend/IniUserGroupBackend.php +++ /dev/null @@ -1,64 +0,0 @@ -config = $config; - } - - /** - * (non-PHPDoc) - * @see UserGroupBackend::getMemberships() For the method documentation. - */ - public function getMemberships(User $user) - { - $username = strtolower($user->getUsername()); - $groups = array(); - foreach ($this->config as $name => $section) { - if (empty($section->users)) { - throw new ConfigurationError( - 'Membership section \'%s\' in \'%s\' is missing the \'users\' section', - $name, - $this->config->getConfigFile() - ); - } - if (empty($section->groups)) { - throw new ConfigurationError( - 'Membership section \'%s\' in \'%s\' is missing the \'groups\' section', - $name, - $this->config->getConfigFile() - ); - } - $users = array_map('strtolower', String::trimSplit($section->users)); - if (in_array($username, $users)) { - $groups = array_merge($groups, array_diff(String::trimSplit($section->groups), $groups)); - } - } - return $groups; - } -} diff --git a/library/Icinga/Authentication/Backend/LdapUserBackend.php b/library/Icinga/Authentication/Backend/LdapUserBackend.php deleted file mode 100644 index 858fa742e..000000000 --- a/library/Icinga/Authentication/Backend/LdapUserBackend.php +++ /dev/null @@ -1,291 +0,0 @@ - 'uid', - 'user' => 'user', - 'inetorgperson' => 'inetOrgPerson', - 'samaccountname' => 'sAMAccountName' - ); - - public function __construct( - Connection $conn, - $userClass, - $userNameAttribute, - $baseDn, - $cutomFilter, - $groupOptions = null - ) { - $this->conn = $conn; - $this->baseDn = trim($baseDn) ?: $conn->getDN(); - $this->userClass = $this->getNormedAttribute($userClass); - $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); - $this->customFilter = trim($cutomFilter); - $this->groupOptions = $groupOptions; - } - - /** - * Return the given attribute name normed to known LDAP enviroments, if possible - * - * @param string $name - * - * @return string - */ - protected function getNormedAttribute($name) - { - $loweredName = strtolower($name); - if (array_key_exists($loweredName, $this->normedAttributes)) { - return $this->normedAttributes[$loweredName]; - } - - return $name; - } - - /** - * Create a query to select all usernames - * - * @return Query - */ - protected function selectUsers() - { - $query = $this->conn->select()->setBase($this->baseDn)->from( - $this->userClass, - array( - $this->userNameAttribute - ) - ); - - if ($this->customFilter) { - $query->addFilter(new Expression($this->customFilter)); - } - - return $query; - } - - /** - * Create a query filtered by the given username - * - * @param string $username - * - * @return Query - */ - protected function selectUser($username) - { - return $this->selectUsers()->setUsePagedResults(false)->where( - $this->userNameAttribute, - str_replace('*', '', $username) - ); - } - - /** - * Probe the backend to test if authentication is possible - * - * Try to bind to the backend and query all available users to check if: - * - * - * @throws AuthenticationException When authentication is not possible - */ - public function assertAuthenticationPossible() - { - try { - $result = $this->selectUsers()->fetchRow(); - } catch (LdapException $e) { - throw new AuthenticationException('Connection not possible.', $e); - } - - if ($result === null) { - throw new AuthenticationException( - 'No objects with objectClass="%s" in DN="%s" found. (Filter: %s)', - $this->userClass, - $this->baseDn, - $this->customFilter ?: 'None' - ); - } - - if (! isset($result->{$this->userNameAttribute})) { - throw new AuthenticationException( - 'UserNameAttribute "%s" not existing in objectClass="%s"', - $this->userNameAttribute, - $this->userClass - ); - } - } - - /** - * Retrieve the user groups - * - * @TODO: Subject to change, see #7343 - * - * @param string $dn - * - * @return array - */ - public function getGroups($dn) - { - if (empty($this->groupOptions) || ! isset($this->groupOptions['group_base_dn'])) { - return array(); - } - - $q = $this->conn->select() - ->setBase($this->groupOptions['group_base_dn']) - ->from( - $this->groupOptions['group_class'], - array($this->groupOptions['group_attribute']) - ) - ->where( - $this->groupOptions['group_member_attribute'], - $dn - ); - - $result = $this->conn->fetchAll($q); - - $groups = array(); - - foreach ($result as $group) { - $groups[] = $group->{$this->groupOptions['group_attribute']}; - } - - return $groups; - } - - /** - * Return whether the given user exists - * - * @param User $user - * - * @return bool - */ - public function hasUser(User $user) - { - $username = $user->getUsername(); - $entry = $this->selectUser($username)->fetchOne(); - - if (is_array($entry)) { - return in_array(strtolower($username), array_map('strtolower', $entry)); - } - - return strtolower($entry) === strtolower($username); - } - - /** - * Return whether the given user credentials are valid - * - * @param User $user - * @param string $password - * @param boolean $healthCheck Assert that authentication is possible at all - * - * @return bool - * - * @throws AuthenticationException In case an error occured or the health check has failed - */ - public function authenticate(User $user, $password, $healthCheck = false) - { - if ($healthCheck) { - try { - $this->assertAuthenticationPossible(); - } catch (AuthenticationException $e) { - throw new AuthenticationException( - 'Authentication against backend "%s" not possible.', - $this->getName(), - $e - ); - } - } - - if (! $this->hasUser($user)) { - return false; - } - - try { - $userDn = $this->conn->fetchDN($this->selectUser($user->getUsername())); - $authenticated = $this->conn->testCredentials( - $userDn, - $password - ); - - if ($authenticated) { - $groups = $this->getGroups($userDn); - if ($groups !== null) { - $user->setGroups($groups); - } - } - - return $authenticated; - } catch (LdapException $e) { - throw new AuthenticationException( - 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', - $user->getUsername(), - $this->getName(), - $e - ); - } - } - - /** - * Get the number of users available - * - * @return int - */ - public function count() - { - return $this->selectUsers()->count(); - } - - /** - * Return the names of all available users - * - * @return array - */ - public function listUsers() - { - $users = array(); - foreach ($this->selectUsers()->fetchAll() as $row) { - if (is_array($row->{$this->userNameAttribute})) { - foreach ($row->{$this->userNameAttribute} as $col) { - $users[] = $col; - } - } else { - $users[] = $row->{$this->userNameAttribute}; - } - } - return $users; - } -} diff --git a/library/Icinga/Authentication/Manager.php b/library/Icinga/Authentication/Manager.php index 5705b493b..fed012ab5 100644 --- a/library/Icinga/Authentication/Manager.php +++ b/library/Icinga/Authentication/Manager.php @@ -4,6 +4,7 @@ namespace Icinga\Authentication; use Exception; +use Icinga\Authentication\UserGroup\UserGroupBackend; use Icinga\Application\Config; use Icinga\Exception\IcingaException; use Icinga\Exception\NotReadableError; @@ -55,7 +56,7 @@ class Manager } catch (NotReadableError $e) { Logger::error( new IcingaException( - 'Cannot load preferences for user "%s". An exception was thrown', + 'Cannot load preferences for user "%s". An exception was thrown: %s', $username, $e ) @@ -73,7 +74,7 @@ class Manager } catch (Exception $e) { Logger::error( new IcingaException( - 'Cannot load preferences for user "%s". An exception was thrown', + 'Cannot load preferences for user "%s". An exception was thrown: %s', $username, $e ) @@ -91,7 +92,7 @@ class Manager $groupsFromBackend = $groupBackend->getMemberships($user); } catch (Exception $e) { Logger::error( - 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown:', + 'Can\'t get group memberships for user \'%s\' from backend \'%s\'. An exception was thrown: %s', $username, $name, $e diff --git a/library/Icinga/Authentication/User/DbUserBackend.php b/library/Icinga/Authentication/User/DbUserBackend.php new file mode 100644 index 000000000..0e5dad5fa --- /dev/null +++ b/library/Icinga/Authentication/User/DbUserBackend.php @@ -0,0 +1,249 @@ + array( + 'user' => 'name COLLATE utf8_general_ci', + 'user_name' => 'name', + 'is_active' => 'active', + 'created_at' => 'UNIX_TIMESTAMP(ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(mtime)' + ) + ); + + /** + * The statement columns being provided + * + * @var array + */ + protected $statementColumns = array( + 'user' => array( + 'password' => 'password_hash', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $filterColumns = array('user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'user_name' => array( + 'columns' => array( + 'is_active desc', + 'user_name' + ) + ) + ); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'user' => array( + 'password' + ) + ); + + /** + * Initialize this database user backend + */ + protected function init() + { + if (! $this->ds->getTablePrefix()) { + $this->ds->setTablePrefix('icingaweb_'); + } + } + + /** + * Insert a table row with the given data + * + * @param string $table + * @param array $bind + */ + public function insert($table, array $bind) + { + $bind['created_at'] = date('Y-m-d H:i:s'); + $this->ds->insert( + $this->prependTablePrefix($table), + $this->requireStatementColumns($table, $bind), + array( + 'active' => PDO::PARAM_INT, + 'password_hash' => PDO::PARAM_LOB + ) + ); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * @param string $table + * @param array $bind + * @param Filter $filter + */ + public function update($table, array $bind, Filter $filter = null) + { + $bind['last_modified'] = date('Y-m-d H:i:s'); + if ($filter) { + $filter = $this->requireFilter($table, $filter); + } + + $this->ds->update( + $this->prependTablePrefix($table), + $this->requireStatementColumns($table, $bind), + $filter, + array( + 'active' => PDO::PARAM_INT, + 'password_hash' => PDO::PARAM_LOB + ) + ); + } + + /** + * Hash and return the given password + * + * @param string $value + * + * @return string + */ + protected function persistPassword($value) + { + return $this->hashPassword($value); + } + + /** + * Fetch the hashed password for the given user + * + * @param string $username The name of the user + * + * @return string + */ + protected function getPasswordHash($username) + { + if ($this->ds->getDbType() === 'pgsql') { + // Since PostgreSQL version 9.0 the default value for bytea_output is 'hex' instead of 'escape' + $columns = array('password_hash' => 'ENCODE(password_hash, \'escape\')'); + } else { + $columns = array('password_hash'); + } + + $query = $this->ds->select() + ->from($this->prependTablePrefix('user'), $columns) + ->where('name', $username) + ->where('active', true); + $statement = $this->ds->getDbAdapter()->prepare($query->getSelectQuery()); + $statement->execute(); + $statement->bindColumn(1, $lob, PDO::PARAM_LOB); + $statement->fetch(PDO::FETCH_BOUND); + if (is_resource($lob)) { + $lob = stream_get_contents($lob); + } + + return $this->ds->getDbType() === 'pgsql' ? pg_unescape_bytea($lob) : $lob; + } + + /** + * Authenticate the given user + * + * @param User $user + * @param string $password + * + * @return bool True on success, false on failure + * + * @throws AuthenticationException In case authentication is not possible due to an error + */ + public function authenticate(User $user, $password) + { + try { + $passwordHash = $this->getPasswordHash($user->getUsername()); + $passwordSalt = $this->getSalt($passwordHash); + $hashToCompare = $this->hashPassword($password, $passwordSalt); + return $hashToCompare === $passwordHash; + } catch (Exception $e) { + throw new AuthenticationException( + 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', + $user->getUsername(), + $this->getName(), + $e + ); + } + } + + /** + * Extract salt from the given password hash + * + * @param string $hash The hashed password + * + * @return string + */ + protected function getSalt($hash) + { + return substr($hash, strlen(self::HASH_ALGORITHM), self::SALT_LENGTH); + } + + /** + * Return a random salt + * + * The returned salt is safe to be used for hashing a user's password + * + * @return string + */ + protected function generateSalt() + { + return openssl_random_pseudo_bytes(self::SALT_LENGTH); + } + + /** + * Hash a password + * + * @param string $password + * @param string $salt + * + * @return string + */ + protected function hashPassword($password, $salt = null) + { + return crypt($password, self::HASH_ALGORITHM . ($salt !== null ? $salt : $this->generateSalt())); + } +} diff --git a/library/Icinga/Authentication/Backend/ExternalBackend.php b/library/Icinga/Authentication/User/ExternalBackend.php similarity index 64% rename from library/Icinga/Authentication/Backend/ExternalBackend.php rename to library/Icinga/Authentication/User/ExternalBackend.php index fb73c55e8..413c0553b 100644 --- a/library/Icinga/Authentication/Backend/ExternalBackend.php +++ b/library/Icinga/Authentication/User/ExternalBackend.php @@ -1,23 +1,29 @@ name = $name; + return $this; } /** - * Test whether the given user exists + * Return this backend's name * - * @param User $user - * - * @return bool + * @return string */ - public function hasUser(User $user) + public function getName() + { + return $this->name; + } + + /** + * Authenticate the given user + * + * @param User $user + * @param string $password + * + * @return bool True on success, false on failure + * + * @throws AuthenticationException In case authentication is not possible due to an error + */ + public function authenticate(User $user, $password = null) { if (isset($_SERVER['REMOTE_USER'])) { $username = $_SERVER['REMOTE_USER']; $user->setRemoteUserInformation($username, 'REMOTE_USER'); + if ($this->stripUsernameRegexp) { $stripped = preg_replace($this->stripUsernameRegexp, '', $username); if ($stripped !== false) { @@ -61,23 +82,11 @@ class ExternalBackend extends UserBackend $username = $stripped; } } + $user->setUsername($username); return true; } return false; } - - /** - * Authenticate - * - * @param User $user - * @param string $password - * - * @return bool - */ - public function authenticate(User $user, $password = null) - { - return $this->hasUser($user); - } } diff --git a/library/Icinga/Authentication/User/LdapUserBackend.php b/library/Icinga/Authentication/User/LdapUserBackend.php new file mode 100644 index 000000000..154a33a44 --- /dev/null +++ b/library/Icinga/Authentication/User/LdapUserBackend.php @@ -0,0 +1,493 @@ + array( + 'columns' => array( + 'is_active desc', + 'user_name' + ) + ) + ); + + protected $groupOptions; + + /** + * Normed attribute names based on known LDAP environments + * + * @var array + */ + protected $normedAttributes = array( + 'uid' => 'uid', + 'user' => 'user', + 'inetorgperson' => 'inetOrgPerson', + 'samaccountname' => 'sAMAccountName' + ); + + /** + * Set the base DN to use for a query + * + * @param string $baseDn + * + * @return $this + */ + public function setBaseDn($baseDn) + { + if (($baseDn = trim($baseDn))) { + $this->baseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a query + * + * @return string + */ + public function getBaseDn() + { + return $this->baseDn; + } + + /** + * Set the objectClass where to look for users + * + * Sets also the base table name for the underlying repository. + * + * @param string $userClass + * + * @return $this + */ + public function setUserClass($userClass) + { + $this->baseTable = $this->userClass = $this->getNormedAttribute($userClass); + return $this; + } + + /** + * Return the objectClass where to look for users + * + * @return string + */ + public function getUserClass() + { + return $this->userClass; + } + + /** + * Set the attribute name where to find a user's name + * + * @param string $userNameAttribute + * + * @return $this + */ + public function setUserNameAttribute($userNameAttribute) + { + $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a user's name + * + * @return string + */ + public function getUserNameAttribute() + { + return $this->userNameAttribute; + } + + /** + * Set the custom LDAP filter to apply on search queries + * + * @param string $filter + * + * @return $this + */ + public function setFilter($filter) + { + if (($filter = trim($filter))) { + $this->filter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on search queries + * + * @return string + */ + public function getFilter() + { + return $this->filter; + } + + public function setGroupOptions(array $options) + { + $this->groupOptions = $options; + return $this; + } + + public function getGroupOptions() + { + return $this->groupOptions; + } + + /** + * Return the given attribute name normed to known LDAP enviroments, if possible + * + * @param string $name + * + * @return string + */ + protected function getNormedAttribute($name) + { + $loweredName = strtolower($name); + if (array_key_exists($loweredName, $this->normedAttributes)) { + return $this->normedAttributes[$loweredName]; + } + + return $name; + } + + /** + * Apply the given configuration to this backend + * + * @param ConfigObject $config + * + * @return $this + */ + public function setConfig(ConfigObject $config) + { + return $this + ->setBaseDn($config->base_dn) + ->setUserClass($config->user_class) + ->setUserNameAttribute($config->user_name_attribute) + ->setFilter($config->filter); + } + + /** + * Return a new query for the given columns + * + * @param array $columns The desired columns, if null all columns will be queried + * + * @return RepositoryQuery + */ + public function select(array $columns = null) + { + $query = parent::select($columns); + $query->getQuery()->setBase($this->baseDn); + if ($this->filter) { + $query->getQuery()->where(new Expression($this->filter)); + } + + return $query; + } + + /** + * Initialize this repository's query columns + * + * @return array + * + * @throws ProgrammingError In case either $this->userNameAttribute or $this->userClass has not been set yet + */ + protected function initializeQueryColumns() + { + if ($this->userClass === null) { + throw new ProgrammingError('It is required to set the objectClass where to look for users first'); + } + if ($this->userNameAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a user\'s name first'); + } + + if ($this->ds->getCapabilities()->hasAdOid()) { + $isActiveAttribute = 'userAccountControl'; + $createdAtAttribute = 'whenCreated'; + $lastModifiedAttribute = 'whenChanged'; + } else { + // TODO(jom): Elaborate whether it is possible to add dynamic support for the ppolicy + $isActiveAttribute = 'shadowExpire'; + + $createdAtAttribute = 'createTimestamp'; + $lastModifiedAttribute = 'modifyTimestamp'; + } + + return array( + $this->userClass => array( + 'user' => $this->userNameAttribute, + 'user_name' => $this->userNameAttribute, + 'is_active' => $isActiveAttribute, + 'created_at' => $createdAtAttribute, + 'last_modified' => $lastModifiedAttribute + ) + ); + } + + /** + * Initialize this repository's conversion rules + * + * @return array + * + * @throws ProgrammingError In case $this->userClass has not been set yet + */ + protected function initializeConversionRules() + { + if ($this->userClass === null) { + throw new ProgrammingError('It is required to set the objectClass where to look for users first'); + } + + if ($this->ds->getCapabilities()->hasAdOid()) { + $stateConverter = 'user_account_control'; + } else { + $stateConverter = 'shadow_expire'; + } + + return array( + $this->userClass => array( + 'is_active' => $stateConverter, + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ) + ); + } + + /** + * Return whether the given userAccountControl value defines that a user is permitted to login + * + * @param string|null $value + * + * @return bool + */ + protected function retrieveUserAccountControl($value) + { + if ($value === null) { + return $value; + } + + $ADS_UF_ACCOUNTDISABLE = 2; + return ((int) $value & $ADS_UF_ACCOUNTDISABLE) === 0; + } + + /** + * Parse the given value based on the ASN.1 standard (GeneralizedTime) and return its timestamp representation + * + * @param string|null $value + * + * @return int + */ + protected function retrieveGeneralizedTime($value) + { + if ($value === null) { + return $value; + } + + if ( + ($dateTime = DateTime::createFromFormat('YmdHis.uO', $value)) !== false + || ($dateTime = DateTime::createFromFormat('YmdHis.uZ', $value)) !== false + || ($dateTime = DateTime::createFromFormat('YmdHis.u', $value)) !== false + || ($dateTime = DateTime::createFromFormat('YmdHis', $value)) !== false + || ($dateTime = DateTime::createFromFormat('YmdHi', $value)) !== false + || ($dateTime = DateTime::createFromFormat('YmdH', $value)) !== false + ) { + return $dateTime->getTimeStamp(); + } else { + Logger::debug(sprintf( + 'Failed to parse "%s" based on the ASN.1 standard (GeneralizedTime) for user backend "%s".', + $value, + $this->getName() + )); + } + } + + /** + * Return whether the given shadowExpire value defines that a user is permitted to login + * + * @param string|null $value + * + * @return bool + */ + protected function retrieveShadowExpire($value) + { + if ($value === null) { + return $value; + } + + $now = new DateTime(); + $bigBang = clone $now; + $bigBang->setTimestamp(0); + return ((int) $value) >= $bigBang->diff($now)->days; + } + + /** + * Probe the backend to test if authentication is possible + * + * Try to bind to the backend and fetch a single user to check if: + * + * + * @throws AuthenticationException When authentication is not possible + */ + public function assertAuthenticationPossible() + { + try { + $result = $this->select()->fetchRow(); + } catch (LdapException $e) { + throw new AuthenticationException('Connection not possible.', $e); + } + + if ($result === null) { + throw new AuthenticationException( + 'No objects with objectClass "%s" in DN "%s" found. (Filter: %s)', + $this->userClass, + $this->baseDn ?: $this->ds->getDn(), + $this->filter ?: 'None' + ); + } + + if (! isset($result->user_name)) { + throw new AuthenticationException( + 'UserNameAttribute "%s" not existing in objectClass "%s"', + $this->userNameAttribute, + $this->userClass + ); + } + } + + /** + * Retrieve the user groups + * + * @TODO: Subject to change, see #7343 + * + * @param string $dn + * + * @return array + */ + public function getGroups($dn) + { + if (empty($this->groupOptions) || ! isset($this->groupOptions['group_base_dn'])) { + return array(); + } + + $result = $this->ds->select() + ->setBase($this->groupOptions['group_base_dn']) + ->from( + $this->groupOptions['group_class'], + array($this->groupOptions['group_attribute']) + ) + ->where( + $this->groupOptions['group_member_attribute'], + $dn + ) + ->fetchAll(); + + $groups = array(); + foreach ($result as $group) { + $groups[] = $group->{$this->groupOptions['group_attribute']}; + } + + return $groups; + } + + /** + * Authenticate the given user + * + * @param User $user + * @param string $password + * + * @return bool True on success, false on failure + * + * @throws AuthenticationException In case authentication is not possible due to an error + */ + public function authenticate(User $user, $password) + { + try { + $userDn = $this + ->select() + ->where('user_name', str_replace('*', '', $user->getUsername())) + ->getQuery() + ->setUsePagedResults(false) + ->fetchDn(); + + if ($userDn === null) { + return false; + } + + $authenticated = $this->ds->testCredentials($userDn, $password); + if ($authenticated) { + $groups = $this->getGroups($userDn); + if ($groups !== null) { + $user->setGroups($groups); + } + } + + return $authenticated; + } catch (LdapException $e) { + throw new AuthenticationException( + 'Failed to authenticate user "%s" against backend "%s". An exception was thrown:', + $user->getUsername(), + $this->getName(), + $e + ); + } + } +} diff --git a/library/Icinga/Authentication/User/UserBackend.php b/library/Icinga/Authentication/User/UserBackend.php new file mode 100644 index 000000000..3d11289fb --- /dev/null +++ b/library/Icinga/Authentication/User/UserBackend.php @@ -0,0 +1,193 @@ +getModuleManager()->getLoadedModules() as $module) { + foreach ($module->getUserBackends() as $identifier => $className) { + if (array_key_exists($identifier, $providedBy)) { + Logger::warning( + 'Cannot register user backend of type "%s" provided by module "%s".' + . ' The type is already provided by module "%s"', + $identifier, + $module->getName(), + $providedBy[$identifier] + ); + } elseif (in_array($identifier, static::$defaultBackends)) { + Logger::warning( + 'Cannot register user backend of type "%s" provided by module "%s".' + . ' The type is a default type provided by Icinga Web 2', + $identifier, + $module->getName() + ); + } else { + $providedBy[$identifier] = $module->getName(); + static::$customBackends[$identifier] = $className; + } + } + } + } + + /** + * Return the class for the given custom user backend + * + * @param string $identifier The identifier of the custom user backend + * + * @return string|null The name of the class or null in case there was no + * backend found with the given identifier + * + * @throws ConfigurationError In case the class associated to the given identifier does not exist + */ + protected static function getCustomUserBackend($identifier) + { + static::registerCustomUserBackends(); + if (array_key_exists($identifier, static::$customBackends)) { + $className = static::$customBackends[$identifier]; + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot utilize user backend of type "%s". Class "%s" does not exist', + $identifier, + $className + ); + } + + return $className; + } + } + + /** + * Create and return a user backend with the given name and given configuration applied to it + * + * @param string $name + * @param ConfigObject $backendConfig + * + * @return UserBackendInterface + * + * @throws ConfigurationError + */ + public static function create($name, ConfigObject $backendConfig) + { + if ($backendConfig->name !== null) { + $name = $backendConfig->name; + } + + if (! ($backendType = strtolower($backendConfig->backend))) { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" is missing the \'backend\' directive', + $name + ); + } + if ($backendType === 'external') { + $backend = new ExternalBackend($backendConfig); + $backend->setName($name); + return $backend; + } + if (in_array($backendType, static::$defaultBackends)) { + // The default backend check is the first one because of performance reasons: + // Do not attempt to load a custom user backend unless it's actually required + } elseif (($customClass = static::getCustomUserBackend($backendType)) !== null) { + $backend = new $customClass($backendConfig); + if (! is_a($backend, 'Icinga\Authentication\User\UserBackendInterface')) { + throw new ConfigurationError( + 'Cannot utilize user backend of type "%s". Class "%s" does not implement UserBackendInterface', + $backendType, + $customClass + ); + } + + $backend->setName($name); + return $backend; + } else { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" defines an invalid backend type.' + . ' Backend type "%s" is not supported', + $name, + $backendType + ); + } + + if ($backendConfig->resource === null) { + throw new ConfigurationError( + 'Authentication configuration for user backend "%s" is missing the \'resource\' directive', + $name + ); + } + $resource = ResourceFactory::create($backendConfig->resource); + + switch ($backendType) { + case 'db': + $backend = new DbUserBackend($resource); + break; + case 'msldap': + $backend = new LdapUserBackend($resource); + $backend->setBaseDn($backendConfig->base_dn); + $backend->setUserClass($backendConfig->get('user_class', 'user')); + $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'sAMAccountName')); + $backend->setFilter($backendConfig->filter); + $backend->setGroupOptions(array( + 'group_base_dn' => $backendConfig->get('group_base_dn', $resource->getDN()), + 'group_attribute' => $backendConfig->get('group_attribute', 'sAMAccountName'), + 'group_member_attribute' => $backendConfig->get('group_member_attribute', 'member'), + 'group_class' => $backendConfig->get('group_class', 'group') + )); + break; + case 'ldap': + $backend = new LdapUserBackend($resource); + $backend->setBaseDn($backendConfig->base_dn); + $backend->setUserClass($backendConfig->get('user_class', 'inetOrgPerson')); + $backend->setUserNameAttribute($backendConfig->get('user_name_attribute', 'uid')); + $backend->setFilter($backendConfig->filter); + $backend->setGroupOptions(array( + 'group_base_dn' => $backendConfig->group_base_dn, + 'group_attribute' => $backendConfig->group_attribute, + 'group_member_attribute' => $backendConfig->group_member_attribute, + 'group_class' => $backendConfig->group_class + )); + break; + } + + $backend->setName($name); + return $backend; + } +} diff --git a/library/Icinga/Authentication/User/UserBackendInterface.php b/library/Icinga/Authentication/User/UserBackendInterface.php new file mode 100644 index 000000000..cfb2e3753 --- /dev/null +++ b/library/Icinga/Authentication/User/UserBackendInterface.php @@ -0,0 +1,41 @@ +name = $name; - return $this; - } - - /** - * Getter for the backend's name - * - * @return string - */ - public function getName() - { - return $this->name; - } - - public static function create($name, ConfigObject $backendConfig) - { - if ($backendConfig->name !== null) { - $name = $backendConfig->name; - } - if (isset($backendConfig->class)) { - // Use a custom backend class, this is only useful for testing - if (!class_exists($backendConfig->class)) { - throw new ConfigurationError( - 'Authentication configuration for backend "%s" defines an invalid backend class.' - . ' Backend class "%s" not found', - $name, - $backendConfig->class - ); - } - return new $backendConfig->class($backendConfig); - } - if (($backendType = $backendConfig->backend) === null) { - throw new ConfigurationError( - 'Authentication configuration for backend "%s" is missing the backend directive', - $name - ); - } - $backendType = strtolower($backendType); - if ($backendType === 'external') { - $backend = new ExternalBackend($backendConfig); - $backend->setName($name); - return $backend; - } - if ($backendConfig->resource === null) { - throw new ConfigurationError( - 'Authentication configuration for backend "%s" is missing the resource directive', - $name - ); - } - try { - $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource); - } catch (ProgrammingError $e) { - throw new ConfigurationError( - 'Resources not set up. Please contact your Icinga Web administrator' - ); - } - $resource = ResourceFactory::createResource($resourceConfig); - switch ($backendType) { - case 'db': - $backend = new DbUserBackend($resource); - break; - case 'msldap': - $groupOptions = array( - 'group_base_dn' => $backendConfig->get('group_base_dn', $resource->getDN()), - 'group_attribute' => $backendConfig->get('group_attribute', 'sAMAccountName'), - 'group_member_attribute' => $backendConfig->get('group_member_attribute', 'member'), - 'group_class' => $backendConfig->get('group_class', 'group') - ); - $backend = new LdapUserBackend( - $resource, - $backendConfig->get('user_class', 'user'), - $backendConfig->get('user_name_attribute', 'sAMAccountName'), - $backendConfig->get('base_dn', $resource->getDN()), - $backendConfig->get('filter'), - $groupOptions - ); - break; - case 'ldap': - if ($backendConfig->user_class === null) { - throw new ConfigurationError( - 'Authentication configuration for backend "%s" is missing the user_class directive', - $name - ); - } - if ($backendConfig->user_name_attribute === null) { - throw new ConfigurationError( - 'Authentication configuration for backend "%s" is missing the user_name_attribute directive', - $name - ); - } - $groupOptions = array( - 'group_base_dn' => $backendConfig->group_base_dn, - 'group_attribute' => $backendConfig->group_attribute, - 'group_member_attribute' => $backendConfig->group_member_attribute, - 'group_class' => $backendConfig->group_class - ); - $backend = new LdapUserBackend( - $resource, - $backendConfig->user_class, - $backendConfig->user_name_attribute, - $backendConfig->get('base_dn', $resource->getDN()), - $backendConfig->get('filter'), - $groupOptions - ); - break; - default: - throw new ConfigurationError( - 'Authentication configuration for backend "%s" defines an invalid backend type.' - . ' Backend type "%s" is not supported', - $name, - $backendType - ); - } - $backend->setName($name); - return $backend; - } - - /** - * Test whether the given user exists - * - * @param User $user - * - * @return bool - */ - abstract public function hasUser(User $user); - - /** - * Authenticate - * - * @param User $user - * @param string $password - * - * @return bool - */ - abstract public function authenticate(User $user, $password); -} diff --git a/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php new file mode 100644 index 000000000..e59b03258 --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php @@ -0,0 +1,264 @@ + array( + 'group_id' => 'g.id', + 'group' => 'g.name COLLATE utf8_general_ci', + 'group_name' => 'g.name', + 'parent' => 'g.parent', + 'created_at' => 'UNIX_TIMESTAMP(g.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(g.mtime)' + ), + 'group_membership' => array( + 'group_id' => 'gm.group_id', + 'user' => 'gm.username COLLATE utf8_general_ci', + 'user_name' => 'gm.username', + 'created_at' => 'UNIX_TIMESTAMP(gm.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(gm.mtime)' + ) + ); + + /** + * The table aliases being applied + * + * @var array + */ + protected $tableAliases = array( + 'group' => 'g', + 'group_membership' => 'gm' + ); + + /** + * The statement columns being provided + * + * @var array + */ + protected $statementColumns = array( + 'group' => array( + 'group_id' => 'id', + 'group_name' => 'name', + 'parent' => 'parent', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ), + 'group_membership' => array( + 'group_id' => 'group_id', + 'group_name' => 'group_id', + 'user_name' => 'username', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $filterColumns = array('group', 'user'); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'group' => array( + 'parent' => 'group_id' + ), + 'group_membership' => array( + 'group_name' => 'group_id' + ) + ); + + /** + * Initialize this database user group backend + */ + protected function init() + { + if (! $this->ds->getTablePrefix()) { + $this->ds->setTablePrefix('icingaweb_'); + } + } + + /** + * Insert a table row with the given data + * + * @param string $table + * @param array $bind + */ + public function insert($table, array $bind) + { + $bind['created_at'] = date('Y-m-d H:i:s'); + parent::insert($table, $bind); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * @param string $table + * @param array $bind + * @param Filter $filter + */ + public function update($table, array $bind, Filter $filter = null) + { + $bind['last_modified'] = date('Y-m-d H:i:s'); + parent::update($table, $bind, $filter); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + */ + public function delete($table, Filter $filter = null) + { + if ($table === 'group') { + parent::delete('group_membership', $filter); + $idQuery = $this->select(array('group_id')); + if ($filter !== null) { + $idQuery->applyFilter($filter); + } + + $this->update('group', array('parent' => null), Filter::where('parent', $idQuery->fetchColumn())); + } + + parent::delete($table, $filter); + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $groupQuery = $this->ds + ->select() + ->from( + array('g' => $this->prependTablePrefix('group')), + array( + 'group_name' => 'g.name', + 'parent_name' => 'gg.name' + ) + )->joinLeft( + array('gg' => $this->prependTablePrefix('group')), + 'g.parent = gg.id', + array() + ); + + $groups = array(); + foreach ($groupQuery as $group) { + $groups[$group->group_name] = $group->parent_name; + } + + $membershipQuery = $this + ->select() + ->from('group_membership', array('group_name')) + ->where('user_name', $user->getUsername()); + + $memberships = array(); + foreach ($membershipQuery as $membership) { + $memberships[] = $membership->group_name; + $parent = $groups[$membership->group_name]; + while ($parent !== null) { + $memberships[] = $parent; + // Usually a parent is an existing group, but since we do not have a constraint on our table.. + $parent = isset($groups[$parent]) ? $groups[$parent] : null; + } + } + + return $memberships; + } + + /** + * Join group into group_membership + * + * @param RepositoryQuery $query + */ + protected function joinGroup(RepositoryQuery $query) + { + $query->getQuery()->join( + $this->requireTable('group'), + 'gm.group_id = g.id', + array() + ); + } + + /** + * Join group_membership into group + * + * @param RepositoryQuery $query + */ + protected function joinGroupMembership(RepositoryQuery $query) + { + $query->getQuery()->join( + $this->requireTable('group_membership'), + 'g.id = gm.group_id', + array() + ); + } + + /** + * Fetch and return the corresponding id for the given group's name + * + * @param string|array $groupName + * + * @return int + * + * @throws NotFoundError + */ + protected function persistGroupId($groupName) + { + if (! $groupName || empty($groupName) || is_int($groupName)) { + return $groupName; + } + + if (is_array($groupName)) { + if (is_int($groupName[0])) { + return $groupName; // In case the array contains mixed types... + } + + $groupIds = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchColumn(); + if (empty($groupIds)) { + throw new NotFoundError('No groups found matching one of: %s', implode(', ', $groupName)); + } + + return $groupIds; + } + + $groupId = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchOne(); + if ($groupId === false) { + throw new NotFoundError('Group "%s" does not exist', $groupName); + } + + return $groupId; + } +} diff --git a/library/Icinga/Authentication/UserGroup/IniUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/IniUserGroupBackend.php new file mode 100644 index 000000000..53eadc147 --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/IniUserGroupBackend.php @@ -0,0 +1,121 @@ + array( + 'group' => 'name', + 'group_name' => 'name', + 'parent' => 'parent', + 'created_at' => 'ctime', + 'last_modified' => 'mtime', + 'users' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $filterColumns = array('group'); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'groups' => array( + 'created_at' => 'date_time', + 'last_modified' => 'date_time', + 'users' => 'comma_separated_string' + ) + ); + + /** + * Initialize this ini user group backend + */ + protected function init() + { + $this->ds->getConfigObject()->setKeyColumn('name'); + } + + /** + * Add a new group to this backend + * + * @param string $target + * @param array $data + * + * @throws StatementException In case the operation has failed + */ + public function insert($target, array $data) + { + $data['created_at'] = time(); + parent::insert($target, $data); + } + + /** + * Update groups of this backend, optionally limited using a filter + * + * @param string $target + * @param array $data + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function update($target, array $data, Filter $filter = null) + { + $data['last_modified'] = time(); + parent::update($target, $data, $filter); + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $result = $this->select()->fetchAll(); + + $groups = array(); + foreach ($result as $group) { + $groups[$group->group_name] = $group->parent; + } + + $username = strtolower($user->getUsername()); + $memberships = array(); + foreach ($result as $group) { + if ($group->users && !in_array($group->group_name, $memberships)) { + $users = array_map('strtolower', String::trimSplit($group->users)); + if (in_array($username, $users)) { + $memberships[] = $group->group_name; + $parent = $groups[$group->group_name]; + while ($parent !== null) { + $memberships[] = $parent; + $parent = isset($groups[$parent]) ? $groups[$parent] : null; + } + } + } + } + + return $memberships; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackend.php b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php new file mode 100644 index 000000000..dd4900ea8 --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php @@ -0,0 +1,164 @@ +getModuleManager()->getLoadedModules() as $module) { + foreach ($module->getUserGroupBackends() as $identifier => $className) { + if (array_key_exists($identifier, $providedBy)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is already provided by module "%s"', + $identifier, + $module->getName(), + $providedBy[$identifier] + ); + } elseif (in_array($identifier, static::$defaultBackends)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is a default type provided by Icinga Web 2', + $identifier, + $module->getName() + ); + } else { + $providedBy[$identifier] = $module->getName(); + static::$customBackends[$identifier] = $className; + } + } + } + } + + /** + * Return the class for the given custom user group backend + * + * @param string $identifier The identifier of the custom user group backend + * + * @return string|null The name of the class or null in case there was no + * backend found with the given identifier + * + * @throws ConfigurationError In case the class associated to the given identifier does not exist + */ + protected static function getCustomUserGroupBackend($identifier) + { + static::registerCustomUserGroupBackends(); + if (array_key_exists($identifier, static::$customBackends)) { + $className = static::$customBackends[$identifier]; + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s". Class "%s" does not exist', + $identifier, + $className + ); + } + + return $className; + } + } + + /** + * Create and return a user group backend with the given name and given configuration applied to it + * + * @param string $name + * @param ConfigObject $backendConfig + * + * @return UserGroupBackendInterface + * + * @throws ConfigurationError + */ + public static function create($name, ConfigObject $backendConfig) + { + if ($backendConfig->name !== null) { + $name = $backendConfig->name; + } + + if (! ($backendType = strtolower($backendConfig->backend))) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'backend\' directive', + $name + ); + } + if (in_array($backendType, static::$defaultBackends)) { + // The default backend check is the first one because of performance reasons: + // Do not attempt to load a custom user group backend unless it's actually required + } elseif (($customClass = static::getCustomUserGroupBackend($backendType)) !== null) { + $backend = new $customClass($backendConfig); + if (! is_a($backend, 'Icinga\Authentication\UserGroup\UserGroupBackendInterface')) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s".' + . ' Class "%s" does not implement UserGroupBackendInterface', + $backendType, + $customClass + ); + } + + $backend->setName($name); + return $backend; + } else { + throw new ConfigurationError( + 'Configuration for user group backend "%s" defines an invalid backend type.' + . ' Backend type "%s" is not supported', + $name, + $backendType + ); + } + + if ($backendConfig->resource === null) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'resource\' directive', + $name + ); + } + $resource = ResourceFactory::create($backendConfig->resource); + + switch ($backendType) { + case 'db': + $backend = new DbUserGroupBackend($resource); + break; + case 'ini': + $backend = new IniUserGroupBackend($resource); + break; + } + + $backend->setName($name); + return $backend; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php new file mode 100644 index 000000000..a567d1f0a --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php @@ -0,0 +1,37 @@ +name = (string) $name; - return $this; - } - - /** - * Get the backend name - * - * @return string - */ - public function getName() - { - return $this->name; - } - - /** - * Create a user group backend - * - * @param string $name - * @param ConfigObject $backendConfig - * - * @return DbUserGroupBackend|IniUserGroupBackend - * @throws ConfigurationError If the backend configuration is invalid - */ - public static function create($name, ConfigObject $backendConfig) - { - if ($backendConfig->name !== null) { - $name = $backendConfig->name; - } - if (($backendType = $backendConfig->backend) === null) { - throw new ConfigurationError( - 'Configuration for user group backend \'%s\' is missing the \'backend\' directive', - $name - ); - } - $backendType = strtolower($backendType); - if (($resourceName = $backendConfig->resource) === null) { - throw new ConfigurationError( - 'Configuration for user group backend \'%s\' is missing the \'resource\' directive', - $name - ); - } - $resourceName = strtolower($resourceName); - try { - $resource = ResourceFactory::create($resourceName); - } catch (IcingaException $e) { - throw new ConfigurationError( - 'Can\'t create user group backend \'%s\'. An exception was thrown: %s', - $resourceName, - $e - ); - } - switch ($backendType) { - case 'db': - $backend = new DbUserGroupBackend($resource); - break; - case 'ini': - $backend = new IniUserGroupBackend($resource); - break; - default: - throw new ConfigurationError( - 'Can\'t create user group backend \'%s\'. Invalid backend type \'%s\'.', - $name, - $backendType - ); - } - $backend->setName($name); - return $backend; - } - - /** - * Get the groups the given user is a member of - * - * @param User $user - * - * @return array - */ - abstract public function getMemberships(User $user); -} diff --git a/library/Icinga/Data/ConfigObject.php b/library/Icinga/Data/ConfigObject.php index 641d72846..de00e5b5e 100644 --- a/library/Icinga/Data/ConfigObject.php +++ b/library/Icinga/Data/ConfigObject.php @@ -4,22 +4,15 @@ namespace Icinga\Data; use Iterator; -use Countable; use ArrayAccess; -use LogicException; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Exception\ProgrammingError; /** * Container for configuration values */ -class ConfigObject implements Countable, Iterator, ArrayAccess +class ConfigObject extends ArrayDatasource implements Iterator, ArrayAccess { - /** - * This config's data - * - * @var array - */ - protected $data; - /** * Create a new config * @@ -27,15 +20,14 @@ class ConfigObject implements Countable, Iterator, ArrayAccess */ public function __construct(array $data = array()) { - $this->data = array(); - - foreach ($data as $key => $value) { + // Convert all embedded arrays to ConfigObjects as well + foreach ($data as & $value) { if (is_array($value)) { - $this->data[$key] = new static($value); - } else { - $this->data[$key] = $value; + $value = new static($value); } } + + parent::__construct($data); } /** @@ -55,16 +47,6 @@ class ConfigObject implements Countable, Iterator, ArrayAccess $this->data = $array; } - /** - * Return the count of available sections and properties - * - * @return int - */ - public function count() - { - return count($this->data); - } - /** * Reset the current position of $this->data * @@ -197,7 +179,7 @@ class ConfigObject implements Countable, Iterator, ArrayAccess public function offsetSet($key, $value) { if ($key === null) { - throw new LogicException('Appending values without an explicit key is not supported'); + throw new ProgrammingError('Appending values without an explicit key is not supported'); } $this->$key = $value; diff --git a/library/Icinga/Data/DataArray/ArrayDatasource.php b/library/Icinga/Data/DataArray/ArrayDatasource.php index 72ccc4b4c..ef5b4e3ed 100644 --- a/library/Icinga/Data/DataArray/ArrayDatasource.php +++ b/library/Icinga/Data/DataArray/ArrayDatasource.php @@ -3,35 +3,105 @@ namespace Icinga\Data\DataArray; +use ArrayIterator; use Icinga\Data\Selectable; use Icinga\Data\SimpleQuery; class ArrayDatasource implements Selectable { + /** + * The array being used as data source + * + * @var array + */ protected $data; + /** + * The current result + * + * @var array + */ protected $result; /** - * Constructor, create a new Datasource for the given Array + * The result of a counted query * - * @param array $array The array you're going to use as a data source + * @var int */ - public function __construct(array $array) + protected $count; + + /** + * The name of the column to map array keys on + * + * In case the array being used as data source provides keys of type string,this name + * will be used to set such as column on each row, if the column is not set already. + * + * @var string + */ + protected $keyColumn; + + /** + * Create a new data source for the given array + * + * @param array $data The array you're going to use as a data source + */ + public function __construct(array $data) { - $this->data = (array) $array; + $this->data = $data; } /** - * Instantiate a Query object + * Set the name of the column to map array keys on * - * @return SimpleQuery + * @param string $name + * + * @return $this + */ + public function setKeyColumn($name) + { + $this->keyColumn = $name; + return $this; + } + + /** + * Return the name of the column to map array keys on + * + * @return string + */ + public function getKeyColumn() + { + return $this->keyColumn; + } + + /** + * Provide a query for this data source + * + * @return SimpleQuery */ public function select() { return new SimpleQuery($this); } + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param SimpleQuery $query + * + * @return ArrayIterator + */ + public function query(SimpleQuery $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + + /** + * Fetch and return a column of all rows of the result set as an array + * + * @param SimpleQuery $query + * + * @return array + */ public function fetchColumn(SimpleQuery $query) { $result = array(); @@ -39,9 +109,17 @@ class ArrayDatasource implements Selectable $arr = (array) $row; $result[] = array_shift($arr); } + return $result; } + /** + * Fetch and return all rows of the given query's result as a flattened key/value based array + * + * @param SimpleQuery $query + * + * @return array + */ public function fetchPairs(SimpleQuery $query) { $result = array(); @@ -53,104 +131,164 @@ class ArrayDatasource implements Selectable $keys[1] = $keys[0]; } } + $result[$row->{$keys[0]}] = $row->{$keys[1]}; } + return $result; } + /** + * Fetch and return the first row of the given query's result + * + * @param SimpleQuery $query + * + * @return object|false The row or false in case the result is empty + */ public function fetchRow(SimpleQuery $query) { $result = $this->getResult($query); if (empty($result)) { return false; } - return $result[0]; + + return array_shift($result); } + /** + * Fetch and return all rows of the given query's result as an array + * + * @param SimpleQuery $query + * + * @return array + */ public function fetchAll(SimpleQuery $query) { return $this->getResult($query); } + /** + * Count all rows of the given query's result + * + * @param SimpleQuery $query + * + * @return int + */ public function count(SimpleQuery $query) { - $this->createResult($query); - return count($this->result); + if ($this->count === null) { + $this->count = count($this->createResult($query)); + } + + return $this->count; } + /** + * Create and return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ protected function createResult(SimpleQuery $query) { - if ($this->hasResult()) { - return $this; - } - $result = array(); - $columns = $query->getColumns(); $filter = $query->getFilter(); - foreach ($this->data as & $row) { + $offset = $query->hasOffset() ? $query->getOffset() : 0; + $limit = $query->hasLimit() ? $query->getLimit() : 0; + + $foundStringKey = false; + $result = array(); + $skipped = 0; + foreach ($this->data as $key => $row) { + if (is_string($key) && $this->keyColumn !== null && !isset($row->{$this->keyColumn})) { + $row = clone $row; // Make sure that this won't affect the actual data + $row->{$this->keyColumn} = $key; + } if (! $filter->matches($row)) { continue; + } elseif ($skipped < $offset) { + $skipped++; + continue; } // Get only desired columns if asked so - if (empty($columns)) { - $result[] = $row; - } else { - $c_row = (object) array(); - foreach ($columns as $alias => $key) { - if (is_int($alias)) { - $alias = $key; + if (! empty($columns)) { + $filteredRow = (object) array(); + foreach ($columns as $alias => $name) { + if (! is_string($alias)) { + $alias = $name; } - if (isset($row->$key)) { - $c_row->$alias = $row->$key; + + if (isset($row->$name)) { + $filteredRow->$alias = $row->$name; } else { - $c_row->$alias = null; + $filteredRow->$alias = null; } } - $result[] = $c_row; + } else { + $filteredRow = $row; + } + + $foundStringKey |= is_string($key); + $result[$key] = $filteredRow; + + if (count($result) === $limit) { + break; } } // Sort the result - if ($query->hasOrder()) { - usort($result, array($query, 'compare')); - } - - $this->setResult($result); - return $this; - } - - protected function getLimitedResult($query) - { - if ($query->hasLimit()) { - if ($query->hasOffset()) { - $offset = $query->getOffset(); + if ($foundStringKey) { + uasort($result, array($query, 'compare')); } else { - $offset = 0; + usort($result, array($query, 'compare')); } - return array_slice($this->result, $offset, $query->getLimit()); - } else { - return $this->result; + } elseif (! $foundStringKey) { + $result = array_values($result); } + + return $result; } + /** + * Return whether a query result exists + * + * @return bool + */ protected function hasResult() { return $this->result !== null; } - protected function setResult($result) + /** + * Set the current result + * + * @param array $result + * + * @return $this + */ + protected function setResult(array $result) { - return $this->result = $result; + $this->result = $result; + return $this; } + /** + * Return the result for the given query + * + * @param SimpleQuery $query + * + * @return array + */ protected function getResult(SimpleQuery $query) { if (! $this->hasResult()) { - $this->createResult($query); + $this->setResult($this->createResult($query)); } - return $this->getLimitedResult($query); + + return $this->result; } } diff --git a/library/Icinga/Data/Db/DbConnection.php b/library/Icinga/Data/Db/DbConnection.php index d1817d24a..471905907 100644 --- a/library/Icinga/Data/Db/DbConnection.php +++ b/library/Icinga/Data/Db/DbConnection.php @@ -4,18 +4,26 @@ namespace Icinga\Data\Db; use PDO; +use Iterator; use Zend_Db; -use Icinga\Application\Benchmark; use Icinga\Data\ConfigObject; use Icinga\Data\Db\DbQuery; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterAnd; +use Icinga\Data\Filter\FilterNot; +use Icinga\Data\Filter\FilterOr; +use Icinga\Data\Reducible; use Icinga\Data\ResourceFactory; use Icinga\Data\Selectable; +use Icinga\Data\Updatable; use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; /** * Encapsulate database connections and query creation */ -class DbConnection implements Selectable +class DbConnection implements Selectable, Extensible, Updatable, Reducible { /** * Connection config @@ -72,13 +80,25 @@ class DbConnection implements Selectable /** * Provide a query on this connection * - * @return Query + * @return DbQuery */ public function select() { return new DbQuery($this); } + /** + * Fetch and return all rows of the given query's result set using an iterator + * + * @param DbQuery $query + * + * @return Iterator + */ + public function query(DbQuery $query) + { + return $query->getSelectQuery()->query(); + } + /** * Getter for database type * @@ -191,6 +211,18 @@ class DbConnection implements Selectable return $this; } + /** + * Count all rows of the result set + * + * @param DbQuery $query + * + * @return int + */ + public function count(DbQuery $query) + { + return (int) $this->dbAdapter->fetchOne($query->getCountQuery()); + } + /** * Retrieve an array containing all rows of the result set * @@ -200,10 +232,7 @@ class DbConnection implements Selectable */ public function fetchAll(DbQuery $query) { - Benchmark::measure('DB is fetching All'); - $result = $this->dbAdapter->fetchAll($query->getSelectQuery()); - Benchmark::measure('DB fetch done'); - return $result; + return $this->dbAdapter->fetchAll($query->getSelectQuery()); } /** @@ -215,21 +244,17 @@ class DbConnection implements Selectable */ public function fetchRow(DbQuery $query) { - Benchmark::measure('DB is fetching row'); - $result = $this->dbAdapter->fetchRow($query->getSelectQuery()); - Benchmark::measure('DB row done'); - return $result; + return $this->dbAdapter->fetchRow($query->getSelectQuery()); } /** - * Fetch a column of all rows of the result set as an array + * Fetch the first column of all rows of the result set as an array * * @param DbQuery $query - * @param int $columnIndex Index of the column to fetch * * @return array */ - public function fetchColumn(DbQuery $query, $columnIndex = 0) + public function fetchColumn(DbQuery $query) { return $this->dbAdapter->fetchCol($query->getSelectQuery()); } @@ -259,4 +284,158 @@ class DbConnection implements Selectable { return $this->dbAdapter->fetchPairs($query->getSelectQuery()); } + + /** + * Insert a table row with the given data + * + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as third parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param array $types + * + * @return int The number of affected rows + */ + public function insert($table, array $bind, array $types = array()) + { + $values = array(); + foreach ($bind as $column => $_) { + $values[] = ':' . $column; + } + + $sql = 'INSERT INTO ' . $table + . ' (' . join(', ', array_keys($bind)) . ') ' + . 'VALUES (' . join(', ', $values) . ')'; + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * Pass an array with a column name (the same as in $bind) and a PDO::PARAM_* constant as value + * as fourth parameter $types to define a different type than string for a particular column. + * + * @param string $table + * @param array $bind + * @param Filter $filter + * @param array $types + * + * @return int The number of affected rows + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $set = array(); + foreach ($bind as $column => $_) { + $set[] = $column . ' = :' . $column; + } + + $sql = 'UPDATE ' . $table + . ' SET ' . join(', ', $set) + . ($filter ? ' WHERE ' . $this->renderFilter($filter) : ''); + $statement = $this->dbAdapter->prepare($sql); + + foreach ($bind as $column => $value) { + $type = isset($types[$column]) ? $types[$column] : PDO::PARAM_STR; + $statement->bindValue(':' . $column, $value, $type); + } + + $statement->execute(); + return $statement->rowCount(); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + * + * @return int The number of affected rows + */ + public function delete($table, Filter $filter = null) + { + return $this->dbAdapter->delete($table, $filter ? $this->renderFilter($filter) : ''); + } + + /** + * Render and return the given filter as SQL-WHERE clause + * + * @param Filter $filter + * + * @return string + */ + public function renderFilter(Filter $filter, $level = 0) + { + // TODO: This is supposed to supersede DbQuery::renderFilter() + $where = ''; + if ($filter->isChain()) { + if ($filter instanceof FilterAnd) { + $operator = ' AND '; + } elseif ($filter instanceof FilterOr) { + $operator = ' OR '; + } elseif ($filter instanceof FilterNot) { + $operator = ' AND '; + $where .= ' NOT '; + } else { + throw new ProgrammingError('Cannot render filter: %s', get_class($filter)); + } + + if (! $filter->isEmpty()) { + $parts = array(); + foreach ($filter->filters() as $filterPart) { + $part = $this->renderFilter($filterPart, $level + 1); + if ($part) { + $parts[] = $part; + } + } + + if (! empty($parts)) { + if ($level > 0) { + $where .= ' (' . implode($operator, $parts) . ') '; + } else { + $where .= implode($operator, $parts); + } + } + } else { + return ''; // Explicitly return the empty string due to the FilterNot case + } + } else { + $where .= $this->renderFilterExpression($filter); + } + + return $where; + } + + /** + * Render and return the given filter expression + * + * @param Filter $filter + * + * @return string + */ + protected function renderFilterExpression(Filter $filter) + { + $column = $filter->getColumn(); + $sign = $filter->getSign(); + $value = $filter->getExpression(); + + if (is_array($value) && $sign === '=') { + // TODO: Should we support this? Doesn't work for blub* + return $column . ' IN (' . $this->dbAdapter->quote($value) . ')'; + } elseif ($sign === '=' && strpos($value, '*') !== false) { + return $column . ' LIKE ' . $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)); + } elseif ($sign === '!=' && strpos($value, '*') !== false) { + return $column . ' NOT LIKE ' . $this->dbAdapter->quote(preg_replace('~\*~', '%', $value)); + } else { + return $column . ' ' . $sign . ' ' . $this->dbAdapter->quote($value); + } + } } diff --git a/library/Icinga/Data/Db/DbQuery.php b/library/Icinga/Data/Db/DbQuery.php index 4fa7f132d..19849c943 100644 --- a/library/Icinga/Data/Db/DbQuery.php +++ b/library/Icinga/Data/Db/DbQuery.php @@ -4,13 +4,11 @@ namespace Icinga\Data\Db; use Icinga\Data\SimpleQuery; -use Icinga\Application\Benchmark; use Icinga\Data\Filter\FilterChain; -use Icinga\Data\Filter\FilterExpression; use Icinga\Data\Filter\FilterOr; use Icinga\Data\Filter\FilterAnd; use Icinga\Data\Filter\FilterNot; -use Icinga\Exception\IcingaException; +use Icinga\Exception\QueryException; use Zend_Db_Select; /** @@ -68,6 +66,7 @@ class DbQuery extends SimpleQuery protected function init() { $this->db = $this->ds->getDbAdapter(); + $this->select = $this->db->select(); parent::init(); } @@ -77,6 +76,13 @@ class DbQuery extends SimpleQuery return $this; } + public function from($target, array $fields = null) + { + parent::from($target, $fields); + $this->select->from($this->target, array()); + return $this; + } + public function where($condition, $value = null) { // $this->count = $this->select = null; @@ -85,9 +91,6 @@ class DbQuery extends SimpleQuery protected function dbSelect() { - if ($this->select === null) { - $this->select = $this->db->select()->from($this->target, array()); - } return clone $this->select; } @@ -153,7 +156,7 @@ class DbQuery extends SimpleQuery $op = ' AND '; $str .= ' NOT '; } else { - throw new IcingaException( + throw new QueryException( 'Cannot render filter: %s', $filter ); @@ -214,7 +217,7 @@ class DbQuery extends SimpleQuery if (! $value) { /* NOTE: It's too late to throw exceptions, we might finish in __toString - throw new IcingaException(sprintf( + throw new QueryException(sprintf( '"%s" is not a valid time expression', $value )); @@ -298,10 +301,9 @@ class DbQuery extends SimpleQuery public function count() { if ($this->count === null) { - Benchmark::measure('DB is counting'); - $this->count = $this->db->fetchOne($this->getCountQuery()); - Benchmark::measure('DB finished count'); + $this->count = parent::count(); } + return $this->count; } @@ -321,9 +323,7 @@ class DbQuery extends SimpleQuery public function __clone() { - if ($this->select) { - $this->select = clone $this->select; - } + $this->select = clone $this->select; } /** @@ -346,4 +346,137 @@ class DbQuery extends SimpleQuery $this->group = $group; return $this; } + + /** + * Return whether the given table has been joined + * + * @param string $table + * + * @return bool + */ + public function hasJoinedTable($table) + { + $fromPart = $this->select->getPart(Zend_Db_Select::FROM); + if (isset($fromPart[$table])) { + return true; + } + + foreach ($fromPart as $options) { + if ($options['tableName'] === $table && $options['joinType'] !== Zend_Db_Select::FROM) { + return true; + } + } + + return false; + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function join($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add an INNER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinInner($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinInner($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a LEFT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinLeft($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinLeft($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a RIGHT OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinRight($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinRight($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a FULL OUTER JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param string $cond Join on this condition + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinFull($name, $cond, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinFull($name, $cond, $cols, $schema); + return $this; + } + + /** + * Add a CROSS JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinCross($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinCross($name, $cols, $schema); + return $this; + } + + /** + * Add a NATURAL JOIN table and colums to the query + * + * @param array|string|Zend_Db_Expr $name The table name + * @param array|string $cols The columns to select from the joined table + * @param string $schema The database name to specify, if any + * + * @return $this + */ + public function joinNatural($name, $cols = Zend_Db_Select::SQL_WILDCARD, $schema = null) + { + $this->select->joinNatural($name, $cols, $schema); + return $this; + } } diff --git a/library/Icinga/Data/Extensible.php b/library/Icinga/Data/Extensible.php new file mode 100644 index 000000000..8b9f39d42 --- /dev/null +++ b/library/Icinga/Data/Extensible.php @@ -0,0 +1,22 @@ +compare *only*. + * + * @var array + */ + protected $flippedColumns; + /** * The columns you're using to sort the query result * @@ -92,16 +111,6 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate */ protected function init() {} - /** - * Return a iterable for this query's result - * - * @return ArrayIterator - */ - public function getIterator() - { - return new ArrayIterator($this->fetchAll()); - } - /** * Get the data source * @@ -113,11 +122,75 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate } /** - * Choose a table and the colums you are interested in + * Start or rewind the iteration + */ + public function rewind() + { + if ($this->iterator === null) { + $iterator = $this->ds->query($this); + if ($iterator instanceof IteratorAggregate) { + $this->iterator = $iterator->getIterator(); + } else { + $this->iterator = $iterator; + } + } + + $this->iterator->rewind(); + Benchmark::measure('Query result iteration started'); + } + + /** + * Fetch and return the current row of this query's result * - * Query will return all available columns if none are given here + * @return object + */ + public function current() + { + return $this->iterator->current(); + } + + /** + * Return whether the current row of this query's result is valid * - * @return $this + * @return bool + */ + public function valid() + { + if (! $this->iterator->valid()) { + Benchmark::measure('Query result iteration finished'); + return false; + } + + return true; + } + + /** + * Return the key for the current row of this query's result + * + * @return mixed + */ + public function key() + { + return $this->iterator->key(); + } + + /** + * Advance to the next row of this query's result + */ + public function next() + { + $this->iterator->next(); + } + + /** + * Choose a table and the columns you are interested in + * + * Query will return all available columns if none are given here. + * + * @param mixed $target + * @param array $fields + * + * @return $this */ public function from($target, array $fields = null) { @@ -226,32 +299,42 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate return $this; } - public function compare($a, $b, $col_num = 0) + /** + * Compare $a with $b based on this query's sort rules and column aliases + * + * @param object $a + * @param object $b + * @param int $orderIndex + * + * @return int + */ + public function compare($a, $b, $orderIndex = 0) { - // Last column to sort reached, rows are considered being equal - if (! array_key_exists($col_num, $this->order)) { - return 0; - } - $col = $this->order[$col_num][0]; - $dir = $this->order[$col_num][1]; -// TODO: throw Exception if column is missing - //$res = strnatcmp(strtolower($a->$col), strtolower($b->$col)); - $res = @strcmp(strtolower($a->$col), strtolower($b->$col)); - if ($res === 0) { -// return $this->compare($a, $b, $col_num++); - - if (array_key_exists(++$col_num, $this->order)) { - return $this->compare($a, $b, $col_num); - } else { - return 0; - } - + if (! array_key_exists($orderIndex, $this->order)) { + return 0; // Last column to sort reached, rows are considered being equal } - if ($dir === self::SORT_ASC) { - return $res; + if ($this->flippedColumns === null) { + $this->flippedColumns = array_flip($this->columns); + } + + $column = $this->order[$orderIndex][0]; + if (array_key_exists($column, $this->flippedColumns)) { + $column = $this->flippedColumns[$column]; + } + + // TODO: throw Exception if column is missing + //$res = strnatcmp(strtolower($a->$column), strtolower($b->$column)); + $result = @strcmp(strtolower($a->$column), strtolower($b->$column)); + if ($result === 0) { + return $this->compare($a, $b, ++$orderIndex); + } + + $direction = $this->order[$orderIndex][1]; + if ($direction === self::SORT_ASC) { + return $result; } else { - return $res * -1; + return $result * -1; } } @@ -374,7 +457,10 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate */ public function fetchAll() { - return $this->ds->fetchAll($this); + Benchmark::measure('Fetching all results started'); + $results = $this->ds->fetchAll($this); + Benchmark::measure('Fetching all results finished'); + return $results; } /** @@ -384,19 +470,23 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate */ public function fetchRow() { - return $this->ds->fetchRow($this); + Benchmark::measure('Fetching one row started'); + $row = $this->ds->fetchRow($this); + Benchmark::measure('Fetching one row finished'); + return $row; } /** - * Fetch a column of all rows of the result set as an array - * - * @param int $columnIndex Index of the column to fetch + * Fetch the first column of all rows of the result set as an array * * @return array */ - public function fetchColumn($columnIndex = 0) + public function fetchColumn() { - return $this->ds->fetchColumn($this, $columnIndex); + Benchmark::measure('Fetching one column started'); + $values = $this->ds->fetchColumn($this); + Benchmark::measure('Fetching one column finished'); + return $values; } /** @@ -406,7 +496,10 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate */ public function fetchOne() { - return $this->ds->fetchOne($this); + Benchmark::measure('Fetching one value started'); + $value = $this->ds->fetchOne($this); + Benchmark::measure('Fetching one value finished'); + return $value; } /** @@ -418,17 +511,25 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate */ public function fetchPairs() { - return $this->ds->fetchPairs($this); + Benchmark::measure('Fetching pairs started'); + $pairs = $this->ds->fetchPairs($this); + Benchmark::measure('Fetching pairs finished'); + return $pairs; } /** - * Count all rows of the result set + * Count all rows of the result set, ignoring limit and offset * - * @return int + * @return int */ public function count() { - return $this->ds->count($this); + $query = clone $this; + $query->limit(0, 0); + Benchmark::measure('Counting all results started'); + $count = $this->ds->count($query); + Benchmark::measure('Counting all results finished'); + return $count; } /** @@ -441,6 +542,7 @@ class SimpleQuery implements QueryInterface, Queryable, IteratorAggregate public function columns(array $columns) { $this->columns = $columns; + $this->flippedColumns = null; // Reset, due to updated columns return $this; } diff --git a/library/Icinga/Data/Updatable.php b/library/Icinga/Data/Updatable.php new file mode 100644 index 000000000..ec07537cb --- /dev/null +++ b/library/Icinga/Data/Updatable.php @@ -0,0 +1,24 @@ +fetchAll($query)); + } + /** * Return the number of available valid lines. * diff --git a/library/Icinga/Protocol/Ldap/Connection.php b/library/Icinga/Protocol/Ldap/Connection.php index 687f30e62..ba6203aab 100644 --- a/library/Icinga/Protocol/Ldap/Connection.php +++ b/library/Icinga/Protocol/Ldap/Connection.php @@ -3,12 +3,14 @@ namespace Icinga\Protocol\Ldap; -use Icinga\Exception\ProgrammingError; -use Icinga\Protocol\Ldap\Exception as LdapException; -use Icinga\Application\Platform; +use ArrayIterator; use Icinga\Application\Config; use Icinga\Application\Logger; +use Icinga\Application\Platform; use Icinga\Data\ConfigObject; +use Icinga\Data\Selectable; +use Icinga\Exception\ProgrammingError; +use Icinga\Protocol\Ldap\Exception as LdapException; /** * Backend class managing all the LDAP stuff for you. @@ -24,7 +26,7 @@ use Icinga\Data\ConfigObject; * )); * */ -class Connection +class Connection implements Selectable { const LDAP_NO_SUCH_OBJECT = 32; const LDAP_SIZELIMIT_EXCEEDED = 4; @@ -122,11 +124,21 @@ class Connection return $this->root; } + /** + * Provide a query on this connection + * + * @return Query + */ public function select() { return new Query($this); } + public function query(Query $query) + { + return new ArrayIterator($this->fetchAll($query)); + } + public function fetchOne($query, $fields = array()) { $row = (array) $this->fetchRow($query, $fields); @@ -198,13 +210,13 @@ class Connection * @return string Returns the distinguished name, or false when the given query yields no results * @throws LdapException When the query result is empty and contains no DN to fetch */ - public function fetchDN(Query $query, $fields = array()) + public function fetchDn(Query $query, $fields = array()) { $rows = $this->fetchAll($query, $fields); - if (count($rows) !== 1) { + if (count($rows) > 1) { throw new LdapException( 'Cannot fetch single DN for %s', - $query->create() + $query ); } return key($rows); @@ -235,14 +247,9 @@ class Connection $this->connect(); $this->bind(); - $count = 0; - $results = $this->runQuery($query); - while (! empty($results)) { - $count += ldap_count_entries($this->ds, $results); - $results = $this->runQuery($query); - } - - return $count; + // TODO: That's still not the best solution, this should probably not request any attributes + $res = $this->runQuery($query); + return count($res); } public function fetchAll(Query $query, $fields = array()) @@ -266,16 +273,20 @@ class Connection * @return array The matched entries * @throws LdapException */ - protected function runQuery(Query $query, $fields = array()) + protected function runQuery(Query $query, array $fields = null) { $limit = $query->getLimit(); $offset = $query->hasOffset() ? $query->getOffset() - 1 : 0; + if (empty($fields)) { + $fields = $query->getColumns(); + } + $results = @ldap_search( $this->ds, - $query->hasBase() ? $query->getBase() : $this->root_dn, - $query->create(), - empty($fields) ? $query->listFields() : $fields, + $query->getBase() ?: $this->root_dn, + (string) $query, + array_values($fields), 0, // Attributes and values $limit ? $offset + $limit : 0 ); @@ -286,18 +297,14 @@ class Connection throw new LdapException( 'LDAP query "%s" (base %s) failed. Error: %s', - $query->create(), - $query->hasBase() ? $query->getBase() : $this->root_dn, + $query, + $query->getBase() ?: $this->root_dn, ldap_error($this->ds) ); } elseif (ldap_count_entries($this->ds, $results) === 0) { return array(); } - foreach ($query->getSortColumns() as $col) { - ldap_sort($this->ds, $results, $col[0]); - } - $count = 0; $entries = array(); $entry = ldap_first_entry($this->ds, $results); @@ -305,11 +312,15 @@ class Connection $count += 1; if ($offset === 0 || $offset < $count) { $entries[ldap_get_dn($this->ds, $entry)] = $this->cleanupAttributes( - ldap_get_attributes($this->ds, $entry) + ldap_get_attributes($this->ds, $entry), array_flip($fields) ); } } while (($limit === 0 || $limit !== count($entries)) && ($entry = ldap_next_entry($this->ds, $entry))); + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + ldap_free_result($results); return $entries; } @@ -330,28 +341,29 @@ class Connection * * @param Query $query The query to execute * @param array $fields The fields that will be fetched from the matches - * @param int $page_size The maximum page size, defaults to Connection::PAGE_SIZE + * @param int $pageSize The maximum page size, defaults to Connection::PAGE_SIZE * * @return array The matched entries * @throws LdapException * @throws ProgrammingError When executed without available page controls (check with pageControlAvailable() ) */ - protected function runPagedQuery(Query $query, $fields = array(), $pageSize = null) + protected function runPagedQuery(Query $query, array $fields = null, $pageSize = null) { if (! $this->pageControlAvailable($query)) { throw new ProgrammingError('LDAP: Page control not available.'); } + if (! isset($pageSize)) { $pageSize = static::PAGE_SIZE; } $limit = $query->getLimit(); $offset = $query->hasOffset() ? $query->getOffset() - 1 : 0; - $queryString = $query->create(); - $base = $query->hasBase() ? $query->getBase() : $this->root_dn; + $queryString = (string) $query; + $base = $query->getBase() ?: $this->root_dn; if (empty($fields)) { - $fields = $query->listFields(); + $fields = $query->getColumns(); } $count = 0; @@ -362,7 +374,14 @@ class Connection // possibillity server to return an answer in case the pagination extension is missing. ldap_control_paged_result($this->ds, $pageSize, false, $cookie); - $results = @ldap_search($this->ds, $base, $queryString, $fields, 0, $limit ? $offset + $limit : 0); + $results = @ldap_search( + $this->ds, + $base, + $queryString, + array_values($fields), + 0, // Attributes and values + $limit ? $offset + $limit : 0 + ); if ($results === false) { if (ldap_errno($this->ds) === self::LDAP_NO_SUCH_OBJECT) { break; @@ -394,7 +413,7 @@ class Connection $count += 1; if ($offset === 0 || $offset < $count) { $entries[ldap_get_dn($this->ds, $entry)] = $this->cleanupAttributes( - ldap_get_attributes($this->ds, $entry) + ldap_get_attributes($this->ds, $entry), array_flip($fields) ); } } while (($limit === 0 || $limit !== count($entries)) && ($entry = ldap_next_entry($this->ds, $entry))); @@ -420,29 +439,61 @@ class Connection // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by // the server: https://www.ietf.org/rfc/rfc2696.txt ldap_control_paged_result($this->ds, 0, false, $cookie); - ldap_search($this->ds, $base, $queryString, $fields); // Returns no entries, due to the page size + ldap_search($this->ds, $base, $queryString); // Returns no entries, due to the page size } else { // Reset the paged search request so that subsequent requests succeed ldap_control_paged_result($this->ds, 0); } + if ($query->hasOrder()) { + uasort($entries, array($query, 'compare')); + } + return $entries; } - protected function cleanupAttributes($attrs) + protected function cleanupAttributes($attributes, array $requestedFields) { - $clean = (object) array(); - for ($i = 0; $i < $attrs['count']; $i++) { - $attr_name = $attrs[$i]; - if ($attrs[$attr_name]['count'] === 1) { - $clean->$attr_name = $attrs[$attr_name][0]; + // In case the result contains attributes with a differing case than the requested fields, it is + // necessary to create another array to map attributes case insensitively to their requested counterparts. + // This does also apply the virtual alias handling. (Since an LDAP server does not handle such) + $loweredFieldMap = array(); + foreach ($requestedFields as $name => $alias) { + $loweredFieldMap[strtolower($name)] = is_string($alias) ? $alias : $name; + } + + $cleanedAttributes = array(); + for ($i = 0; $i < $attributes['count']; $i++) { + $attribute_name = $attributes[$i]; + if ($attributes[$attribute_name]['count'] === 1) { + $attribute_value = $attributes[$attribute_name][0]; } else { - for ($j = 0; $j < $attrs[$attr_name]['count']; $j++) { - $clean->{$attr_name}[] = $attrs[$attr_name][$j]; + $attribute_value = array(); + for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) { + $attribute_value[] = $attributes[$attribute_name][$j]; } } + + $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)]) + ? $loweredFieldMap[strtolower($attribute_name)] + : $attribute_name; + $cleanedAttributes[$requestedAttributeName] = $attribute_value; } - return $clean; + + // The result may not contain all requested fields, so populate the cleaned + // result with the missing fields and their value being set to null + foreach ($requestedFields as $name => $alias) { + if (! is_string($alias)) { + $alias = $name; + } + + if (! array_key_exists($alias, $cleanedAttributes)) { + $cleanedAttributes[$alias] = null; + Logger::debug('LDAP query result does not provide the requested field "%s"', $name); + } + } + + return (object) $cleanedAttributes; } public function testCredentials($username, $password) @@ -566,6 +617,10 @@ class Connection */ public function getCapabilities() { + if ($this->capabilities === null) { + $this->connect(); // Populates $this->capabilities + } + return $this->capabilities; } @@ -590,30 +645,22 @@ class Connection */ protected function discoverCapabilities($ds) { - $query = $this->select()->from( - '*', - array( - 'defaultNamingContext', - 'namingContexts', - 'vendorName', - 'vendorVersion', - 'supportedSaslMechanisms', - 'dnsHostName', - 'schemaNamingContext', - 'supportedLDAPVersion', // => array(3, 2) - 'supportedCapabilities', - 'supportedControl', - 'supportedExtension', - '+' - ) - ); - $result = @ldap_read( - $ds, - '', - $query->create(), - $query->listFields() + $fields = array( + 'defaultNamingContext', + 'namingContexts', + 'vendorName', + 'vendorVersion', + 'supportedSaslMechanisms', + 'dnsHostName', + 'schemaNamingContext', + 'supportedLDAPVersion', // => array(3, 2) + 'supportedCapabilities', + 'supportedControl', + 'supportedExtension', + '+' ); + $result = @ldap_read($ds, '', (string) $this->select()->from('*', $fields), $fields); if (! $result) { throw new LdapException( 'Capability query failed (%s:%d): %s. Check if hostname and port of the' @@ -623,6 +670,7 @@ class Connection ldap_error($ds) ); } + $entry = ldap_first_entry($ds, $result); if ($entry === false) { throw new LdapException( @@ -633,9 +681,7 @@ class Connection ); } - $ldapAttributes = ldap_get_attributes($ds, $entry); - $result = $this->cleanupAttributes($ldapAttributes); - return new Capability($result); + return new Capability($this->cleanupAttributes(ldap_get_attributes($ds, $entry), array_flip($fields))); } /** diff --git a/library/Icinga/Protocol/Ldap/Query.php b/library/Icinga/Protocol/Ldap/Query.php index 004c2e43e..b54f8b15e 100644 --- a/library/Icinga/Protocol/Ldap/Query.php +++ b/library/Icinga/Protocol/Ldap/Query.php @@ -3,151 +3,154 @@ namespace Icinga\Protocol\Ldap; +use Icinga\Data\SimpleQuery; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\NotImplementedError; + /** - * Search class - * - * @package Icinga\Protocol\Ldap + * LDAP query class */ -/** - * Search abstraction class - * - * Usage example: - * - * - * $connection->select()->from('user')->where('sAMAccountName = ?', 'icinga'); - * - * - * @copyright Copyright (c) 2013 Icinga-Web Team - * @author Icinga-Web Team - * @package Icinga\Protocol\Ldap - * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License - */ -class Query +class Query extends SimpleQuery { - protected $connection; - protected $filters = array(); - protected $fields = array(); - protected $limit_count = 0; - protected $limit_offset = 0; - protected $sort_columns = array(); - protected $count; - protected $base; - protected $usePagedResults = true; + /** + * This query's filters + * + * Currently just a basic key/value pair based array. Can be removed once Icinga\Data\Filter is supported. + * + * @var array + */ + protected $filters; /** - * Constructor + * The base dn being used for this query * - * @param Connection LDAP Connection object - * @return void + * @var string */ - public function __construct(Connection $connection) + protected $base; + + /** + * Whether this query is permitted to utilize paged results + * + * @var bool + */ + protected $usePagedResults; + + /** + * Initialize this query + */ + protected function init() { - $this->connection = $connection; + $this->filters = array(); + $this->usePagedResults = true; } + /** + * Set the base dn to be used for this query + * + * @param string $base + * + * @return $this + */ public function setBase($base) { $this->base = $base; return $this; } - public function hasBase() - { - return $this->base !== null; - } - + /** + * Return the base dn being used for this query + * + * @return string + */ public function getBase() { return $this->base; } + /** + * Set whether this query is permitted to utilize paged results + * + * @param bool $state + * + * @return $this + */ public function setUsePagedResults($state = true) { $this->usePagedResults = (bool) $state; return $this; } + /** + * Return whether this query is permitted to utilize paged results + * + * @return bool + */ public function getUsePagedResults() { return $this->usePagedResults; } /** - * Count result set, ignoring limits + * Choose an objectClass and the columns you are interested in * - * @return int + * {@inheritdoc} This creates an objectClass filter. */ - public function count() + public function from($target, array $fields = null) { - if ($this->count === null) { - $this->count = $this->connection->count($this); - } - return $this->count; + $this->filters['objectClass'] = $target; + return parent::from($target, $fields); } /** - * Count result set, ignoring limits + * Add a new filter to the query * - * @return int + * @param string $condition Column to search in + * @param mixed $value Value to look for (asterisk wildcards are allowed) + * + * @return $this */ - public function limit($count = null, $offset = null) + public function where($condition, $value = null) { - if (! preg_match('~^\d+~', $count . $offset)) { - throw new Exception( - 'Got invalid limit: %s, %s', - $count, - $offset - ); + // TODO: Adjust this once support for Icinga\Data\Filter is available + if ($condition instanceof Expression) { + $this->filters[] = $condition; + } else { + $this->filters[$condition] = $value; } - $this->limit_count = (int) $count; - $this->limit_offset = (int) $offset; + return $this; } - /** - * Whether a limit has been set - * - * @return boolean - */ - public function hasLimit() + public function getFilter() { - return $this->limit_count > 0; + throw new NotImplementedError('Support for Icinga\Data\Filter is still missing. Use $this->where() instead'); } - /** - * Whether an offset (limit) has been set - * - * @return boolean - */ - public function hasOffset() + public function addFilter(Filter $filter) { - return $this->limit_offset > 0; + // TODO: This should be considered a quick fix only. + // Drop this entirely once support for Icinga\Data\Filter is available + if ($filter->isExpression()) { + $this->where($filter->getColumn(), $filter->getExpression()); + } elseif ($filter->isChain()) { + foreach ($filter->filters() as $chainOrExpression) { + $this->addFilter($chainOrExpression); + } + } } - /** - * Retrieve result limit - * - * @return int - */ - public function getLimit() + public function setFilter(Filter $filter) { - return $this->limit_count; - } - - /** - * Retrieve result offset - * - * @return int - */ - public function getOffset() - { - return $this->limit_offset; + throw new NotImplementedError('Support for Icinga\Data\Filter is still missing. Use $this->where() instead'); } /** * Fetch result as tree * - * @return Node + * @return Root + * + * @todo This is untested waste, not being used anywhere and ignores the query's order and base dn. + * Evaluate whether it's reasonable to properly implement and test it. */ public function fetchTree() { @@ -179,132 +182,32 @@ class Query } /** - * Fetch result as an array of objects + * Fetch the distinguished name of the first result * - * @return array + * @return string|false The distinguished name or false in case it's not possible to fetch a result + * + * @throws Exception In case the query returns multiple results + * (i.e. it's not possible to fetch a unique DN) */ - public function fetchAll() + public function fetchDn() { - return $this->connection->fetchAll($this); + return $this->ds->fetchDn($this); } /** - * Fetch first result row + * Return the LDAP filter to be applied on this query * - * @return object + * @return string + * + * @throws Exception In case the objectClass filter does not exist */ - public function fetchRow() + protected function renderFilter() { - return $this->connection->fetchRow($this); - } - - /** - * Fetch first column value from first result row - * - * @return mixed - */ - public function fetchOne() - { - return $this->connection->fetchOne($this); - } - - /** - * Fetch a key/value list, first column is key, second is value - * - * @return array - */ - public function fetchPairs() - { - // STILL TODO!! - return $this->connection->fetchPairs($this); - } - - /** - * Where to select (which fields) from - * - * This creates an objectClass filter - * - * @return Query - */ - public function from($objectClass, $fields = array()) - { - $this->filters['objectClass'] = $objectClass; - $this->fields = $fields; - return $this; - } - - /** - * Add a new filter to the query - * - * @param string Column to search in - * @param string Filter text (asterisks are allowed) - * @return Query - */ - public function where($key, $val) - { - $this->filters[$key] = $val; - return $this; - } - - /** - * Sort by given column - * - * TODO: Sort direction is not implemented yet - * - * @param string Order column - * @param string Order direction - * @return Query - */ - public function order($column, $direction = 'ASC') - { - $this->sort_columns[] = array($column, $direction); - return $this; - } - - /** - * Retrieve a list of the desired fields - * - * @return array - */ - public function listFields() - { - return $this->fields; - } - - /** - * Retrieve a list containing current sort columns - * - * @return array - */ - public function getSortColumns() - { - return $this->sort_columns; - } - - /** - * Add a filter expression to this query - * - * @param Expression $expression - * - * @return Query - */ - public function addFilter(Expression $expression) - { - $this->filters[] = $expression; - return $this; - } - - /** - * Returns the LDAP filter that will be applied - * - * @string - */ - public function create() - { - $parts = array(); - if (! isset($this->filters['objectClass']) || $this->filters['objectClass'] === null) { + if (! isset($this->filters['objectClass'])) { throw new Exception('Object class is mandatory'); } + + $parts = array(); foreach ($this->filters as $key => $value) { if ($value instanceof Expression) { $parts[] = (string) $value; @@ -316,6 +219,7 @@ class Query ); } } + if (count($parts) > 1) { return '(&(' . implode(')(', $parts) . '))'; } else { @@ -323,17 +227,13 @@ class Query } } + /** + * Return the LDAP filter to be applied on this query + * + * @return string + */ public function __toString() { - return $this->create(); - } - - /** - * Descructor - */ - public function __destruct() - { - // To be on the safe side: - unset($this->connection); + return $this->renderFilter(); } } diff --git a/library/Icinga/Repository/DbRepository.php b/library/Icinga/Repository/DbRepository.php new file mode 100644 index 000000000..93f9ceaeb --- /dev/null +++ b/library/Icinga/Repository/DbRepository.php @@ -0,0 +1,755 @@ + + *
  • Support for table aliases
  • + *
  • Automatic table prefix handling
  • + *
  • Insert, update and delete capabilities
  • + *
  • Differentiation between statement and query columns
  • + *
  • Capability to join additional tables depending on the columns being selected or used in a filter
  • + * + */ +abstract class DbRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The datasource being used + * + * @var DbConnection + */ + protected $ds; + + /** + * The table aliases being applied + * + * This must be initialized by repositories which are going to make use of table aliases. Every table for which + * aliased columns are provided must be defined in this array using its name as key and the alias being used as + * value. Failure to do so will result in invalid queries. + * + * @var array + */ + protected $tableAliases; + + /** + * The statement columns being provided + * + * This may be initialized by repositories which are going to make use of table aliases. It allows to provide + * alias-less column names to be used for a statement. The array needs to be in the following format: + *
    
    +     *  array(
    +     *      'table_name' => array(
    +     *          'column1',
    +     *          'alias1' => 'column2',
    +     *          'alias2' => 'column3'
    +     *      )
    +     *  )
    +     * 
    
    +     *
    +     * @var array
    +     */
    +    protected $statementColumns;
    +
    +    /**
    +     * An array to map table names to statement columns/aliases
    +     *
    +     * @var array
    +     */
    +    protected $statementTableMap;
    +
    +    /**
    +     * A flattened array to map statement columns to aliases
    +     *
    +     * @var array
    +     */
    +    protected $statementColumnMap;
    +
    +    /**
    +     * List of columns where the COLLATE SQL-instruction has been removed
    +     *
    +     * This list is being populated in case of a PostgreSQL backend only,
    +     * to ensure case-insensitive string comparison in WHERE clauses.
    +     *
    +     * @var array
    +     */
    +    protected $columnsWithoutCollation;
    +
    +    /**
    +     * Create a new DB repository object
    +     *
    +     * In case $this->queryColumns has already been initialized, this initializes
    +     * $this->columnsWithoutCollation in case of a PostgreSQL connection.
    +     *
    +     * @param   DbConnection    $ds     The datasource to use
    +     */
    +    public function __construct(DbConnection $ds)
    +    {
    +        parent::__construct($ds);
    +
    +        $this->columnsWithoutCollation = array();
    +        if ($ds->getDbType() === 'pgsql' && $this->queryColumns !== null) {
    +            $this->queryColumns = $this->removeCollateInstruction($this->queryColumns);
    +        }
    +    }
    +
    +    /**
    +     * Return the query columns being provided
    +     *
    +     * Initializes $this->columnsWithoutCollation in case of a PostgreSQL connection.
    +     *
    +     * @return  array
    +     */
    +    public function getQueryColumns()
    +    {
    +        if ($this->queryColumns === null) {
    +            $this->queryColumns = parent::getQueryColumns();
    +            if ($this->ds->getDbType() === 'pgsql') {
    +                $this->queryColumns = $this->removeCollateInstruction($this->queryColumns);
    +            }
    +        }
    +
    +        return $this->queryColumns;
    +    }
    +
    +    /**
    +     * Return the table aliases to be applied
    +     *
    +     * Calls $this->initializeTableAliases() in case $this->tableAliases is null.
    +     *
    +     * @return  array
    +     */
    +    public function getTableAliases()
    +    {
    +        if ($this->tableAliases === null) {
    +            $this->tableAliases = $this->initializeTableAliases();
    +        }
    +
    +        return $this->tableAliases;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the table aliases lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeTableAliases()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Remove each COLLATE SQL-instruction from all given query columns
    +     *
    +     * @param   array   $queryColumns
    +     *
    +     * @return  array                   $queryColumns, the updated version
    +     */
    +    protected function removeCollateInstruction($queryColumns)
    +    {
    +        foreach ($queryColumns as & $columns) {
    +            foreach ($columns as & $column) {
    +                $column = preg_replace('/ COLLATE .+$/', '', $column, -1, $count);
    +                if ($count > 0) {
    +                    $this->columnsWithoutCollation[] = $column;
    +                }
    +            }
    +        }
    +
    +        return $queryColumns;
    +    }
    +
    +    /**
    +     * Return the given table with the datasource's prefix being prepended
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  array|string
    +     *
    +     * @throws  IcingaException         In case $table is not of a supported type
    +     */
    +    protected function prependTablePrefix($table)
    +    {
    +        $prefix = $this->ds->getTablePrefix();
    +        if (! $prefix) {
    +            return $table;
    +        }
    +
    +        if (is_array($table)) {
    +            foreach ($table as & $tableName) {
    +                if (strpos($tableName, $prefix) === false) {
    +                    $tableName = $prefix . $tableName;
    +                }
    +            }
    +        } elseif (is_string($table)) {
    +            $table = (strpos($table, $prefix) === false ? $prefix : '') . $table;
    +        } else {
    +            throw new IcingaException('Table prefix handling for type "%s" is not supported', type($table));
    +        }
    +
    +        return $table;
    +    }
    +
    +    /**
    +     * Remove the datasource's prefix from the given table name and return the remaining part
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  array|string
    +     *
    +     * @throws  IcingaException         In case $table is not of a supported type
    +     */
    +    protected function removeTablePrefix($table)
    +    {
    +        $prefix = $this->ds->getTablePrefix();
    +        if (! $prefix) {
    +            return $table;
    +        }
    +
    +        if (is_array($table)) {
    +            foreach ($table as & $tableName) {
    +                if (strpos($tableName, $prefix) === 0) {
    +                    $tableName = str_replace($prefix, '', $tableName);
    +                }
    +            }
    +        } elseif (is_string($table)) {
    +            if (strpos($table, $prefix) === 0) {
    +                $table = str_replace($prefix, '', $table);
    +            }
    +        } else {
    +            throw new IcingaException('Table prefix handling for type "%s" is not supported', type($table));
    +        }
    +
    +        return $table;
    +    }
    +
    +    /**
    +     * Return the given table with its alias being applied
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  array|string
    +     */
    +    protected function applyTableAlias($table)
    +    {
    +        $tableAliases = $this->getTableAliases();
    +        if (is_array($table) || !isset($tableAliases[($nonPrefixedTable = $this->removeTablePrefix($table))])) {
    +            return $table;
    +        }
    +
    +        return array($tableAliases[$nonPrefixedTable] => $table);
    +    }
    +
    +    /**
    +     * Return the given table with its alias being cleared
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  string
    +     *
    +     * @throws  IcingaException         In case $table is not of a supported type
    +     */
    +    protected function clearTableAlias($table)
    +    {
    +        if (is_string($table)) {
    +            return $table;
    +        }
    +
    +        if (is_array($table)) {
    +            return reset($table);
    +        }
    +
    +        throw new IcingaException('Table alias handling for type "%s" is not supported', type($table));
    +    }
    +
    +    /**
    +     * Insert a table row with the given data
    +     *
    +     * @param   string  $table
    +     * @param   array   $bind
    +     */
    +    public function insert($table, array $bind)
    +    {
    +        $this->ds->insert($this->prependTablePrefix($table), $this->requireStatementColumns($table, $bind));
    +    }
    +
    +    /**
    +     * Update table rows with the given data, optionally limited by using a filter
    +     *
    +     * @param   string  $table
    +     * @param   array   $bind
    +     * @param   Filter  $filter
    +     */
    +    public function update($table, array $bind, Filter $filter = null)
    +    {
    +        if ($filter) {
    +            $filter = $this->requireFilter($table, $filter);
    +        }
    +
    +        $this->ds->update($this->prependTablePrefix($table), $this->requireStatementColumns($table, $bind), $filter);
    +    }
    +
    +    /**
    +     * Delete table rows, optionally limited by using a filter
    +     *
    +     * @param   string  $table
    +     * @param   Filter  $filter
    +     */
    +    public function delete($table, Filter $filter = null)
    +    {
    +        if ($filter) {
    +            $filter = $this->requireFilter($table, $filter);
    +        }
    +
    +        $this->ds->delete($this->prependTablePrefix($table), $filter);
    +    }
    +
    +    /**
    +     * Return the statement columns being provided
    +     *
    +     * Calls $this->initializeStatementColumns() in case $this->statementColumns is null.
    +     *
    +     * @return  array
    +     */
    +    public function getStatementColumns()
    +    {
    +        if ($this->statementColumns === null) {
    +            $this->statementColumns = $this->initializeStatementColumns();
    +        }
    +
    +        return $this->statementColumns;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the statement columns lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeStatementColumns()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Return an array to map table names to statement columns/aliases
    +     *
    +     * @return  array
    +     */
    +    protected function getStatementTableMap()
    +    {
    +        if ($this->statementTableMap === null) {
    +            $this->initializeStatementMaps();
    +        }
    +
    +        return $this->statementTableMap;
    +    }
    +
    +    /**
    +     * Return a flattened array to map statement columns to aliases
    +     *
    +     * @return  array
    +     */
    +    protected function getStatementColumnMap()
    +    {
    +        if ($this->statementColumnMap === null) {
    +            $this->initializeStatementMaps();
    +        }
    +
    +        return $this->statementColumnMap;
    +    }
    +
    +    /**
    +     * Initialize $this->statementTableMap and $this->statementColumnMap
    +     */
    +    protected function initializeStatementMaps()
    +    {
    +        $this->statementTableMap = array();
    +        $this->statementColumnMap = array();
    +        foreach ($this->getStatementColumns() as $table => $columns) {
    +            foreach ($columns as $alias => $column) {
    +                $key = is_string($alias) ? $alias : $column;
    +                if (array_key_exists($key, $this->statementTableMap)) {
    +                    if ($this->statementTableMap[$key] !== null) {
    +                        $existingTable = $this->statementTableMap[$key];
    +                        $existingColumn = $this->statementColumnMap[$key];
    +                        $this->statementTableMap[$existingTable . '.' . $key] = $existingTable;
    +                        $this->statementColumnMap[$existingTable . '.' . $key] = $existingColumn;
    +                        $this->statementTableMap[$key] = null;
    +                        $this->statementColumnMap[$key] = null;
    +                    }
    +
    +                    $this->statementTableMap[$table . '.' . $key] = $table;
    +                    $this->statementColumnMap[$table . '.' . $key] = $column;
    +                } else {
    +                    $this->statementTableMap[$key] = $table;
    +                    $this->statementColumnMap[$key] = $column;
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Return whether this repository is capable of converting values
    +     *
    +     * This does not check whether any conversion for the given table is available, as it may be possible
    +     * that columns from another table where joined in which would otherwise not being converted.
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  bool
    +     */
    +    public function providesValueConversion($_)
    +    {
    +        $conversionRules = $this->getConversionRules();
    +        return !empty($conversionRules);
    +    }
    +
    +    /**
    +     * Return the name of the conversion method for the given alias or column name and context
    +     *
    +     * @param   array|string    $table      The datasource's table
    +     * @param   string          $name       The alias or column name for which to return a conversion method
    +     * @param   string          $context    The context of the conversion: persist or retrieve
    +     *
    +     * @return  string
    +     *
    +     * @throws  ProgrammingError    In case a conversion rule is found but not any conversion method
    +     */
    +    protected function getConverter($table, $name, $context)
    +    {
    +        if (
    +            $this->validateQueryColumnAssociation($table, $name)
    +            || $this->validateStatementColumnAssociation($table, $name)
    +        ) {
    +            $table = $this->removeTablePrefix($this->clearTableAlias($table));
    +        } else {
    +            $table = $this->findTableName($name);
    +            if (! $table) {
    +                throw new ProgrammingError('Column name validation seems to have failed. Did you require the column?');
    +            }
    +        }
    +
    +        return parent::getConverter($table, $name, $context);
    +    }
    +
    +    /**
    +     * Validate that the requested table exists
    +     *
    +     * This will prepend the datasource's table prefix and will apply the table's alias, if any.
    +     *
    +     * @param   string              $table      The table to validate
    +     * @param   RepositoryQuery     $query      An optional query to pass as context
    +     *                                          (unused by the base implementation)
    +     *
    +     * @return  array|string
    +     *
    +     * @throws  ProgrammingError                In case the given table does not exist
    +     */
    +    public function requireTable($table, RepositoryQuery $query = null)
    +    {
    +        $statementColumns = $this->getStatementColumns();
    +        if (! isset($statementColumns[$table])) {
    +            $table = parent::requireTable($table);
    +        }
    +
    +        return $this->prependTablePrefix($this->applyTableAlias($table));
    +    }
    +
    +    /**
    +     * Recurse the given filter, require each column for the given table and convert all values
    +     *
    +     * In case of a PostgreSQL connection, this applies LOWER() on the column and strtolower()
    +     * on the value if a COLLATE SQL-instruction is part of the resolved column.
    +     *
    +     * @param   string              $table      The table being filtered
    +     * @param   Filter              $filter     The filter to recurse
    +     * @param   RepositoryQuery     $query      An optional query to pass as context
    +     *                                          (Directly passed through to $this->requireFilterColumn)
    +     * @param   bool                $clone      Whether to clone $filter first
    +     *
    +     * @return  Filter                          The udpated filter
    +     */
    +    public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true)
    +    {
    +        $filter = parent::requireFilter($table, $filter, $query, $clone);
    +
    +        if ($filter->isExpression()) {
    +            $column = $filter->getColumn();
    +            if (in_array($column, $this->columnsWithoutCollation) && strpos($column, 'LOWER') !== 0) {
    +                $filter->setColumn('LOWER(' . $column . ')');
    +                $expression = $filter->getExpression();
    +                if (is_array($expression)) {
    +                    $filter->setExpression(array_map('strtolower', $expression));
    +                } else {
    +                    $filter->setExpression(strtolower($expression));
    +                }
    +            }
    +        }
    +
    +        return $filter;
    +    }
    +
    +    /**
    +     * Return this repository's query columns of the given table mapped to their respective aliases
    +     *
    +     * @param   array|string    $table
    +     *
    +     * @return  array
    +     *
    +     * @throws  ProgrammingError    In case $table does not exist
    +     */
    +    public function requireAllQueryColumns($table)
    +    {
    +        return parent::requireAllQueryColumns($this->removeTablePrefix($this->clearTableAlias($table)));
    +    }
    +
    +    /**
    +     * Return the query column name for the given alias or null in case the alias does not exist
    +     *
    +     * @param   array|string    $table
    +     * @param   string          $alias
    +     *
    +     * @return  string|null
    +     */
    +    public function resolveQueryColumnAlias($table, $alias)
    +    {
    +        return parent::resolveQueryColumnAlias($this->removeTablePrefix($this->clearTableAlias($table)), $alias);
    +    }
    +
    +    /**
    +     * Return whether the given query column name or alias is available in the given table
    +     *
    +     * @param   array|string    $table
    +     * @param   string          $column
    +     *
    +     * @return  bool
    +     */
    +    public function validateQueryColumnAssociation($table, $column)
    +    {
    +        return parent::validateQueryColumnAssociation(
    +            $this->removeTablePrefix($this->clearTableAlias($table)),
    +            $column
    +        );
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid query target and return it or the actual name if it's an alias
    +     *
    +     * Attempts to join the given column from a different table if its association to the given table cannot be
    +     * verified.
    +     *
    +     * @param   string              $table  The table where to look for the column or alias
    +     * @param   string              $name   The name or alias of the column to validate
    +     * @param   RepositoryQuery     $query  An optional query to pass as context,
    +     *                                      if not given no join will be attempted
    +     *
    +     * @return  string                      The given column's name
    +     *
    +     * @throws  QueryException              In case the given column is not a valid query column
    +     */
    +    public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
    +    {
    +        if ($query === null || $this->validateQueryColumnAssociation($table, $name)) {
    +            return parent::requireQueryColumn($table, $name, $query);
    +        }
    +
    +        return $this->joinColumn($name, $table, $query);
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid filter target and return it or the actual name if it's an alias
    +     *
    +     * Attempts to join the given column from a different table if its association to the given table cannot be
    +     * verified.
    +     *
    +     * @param   string              $table  The table where to look for the column or alias
    +     * @param   string              $name   The name or alias of the column to validate
    +     * @param   RepositoryQuery     $query  An optional query to pass as context,
    +     *                                      if not given the column is considered being used for a statement filter
    +     *
    +     * @return  string                      The given column's name
    +     *
    +     * @throws  QueryException              In case the given column is not a valid filter column
    +     */
    +    public function requireFilterColumn($table, $name, RepositoryQuery $query = null)
    +    {
    +        if ($query === null) {
    +            return $this->requireStatementColumn($table, $name);
    +        }
    +
    +        if ($this->validateQueryColumnAssociation($table, $name)) {
    +            return parent::requireFilterColumn($table, $name, $query);
    +        }
    +
    +        return $this->joinColumn($name, $table, $query);
    +    }
    +
    +    /**
    +     * Return the statement column name for the given alias or null in case the alias does not exist
    +     *
    +     * @param   string  $table
    +     * @param   string  $alias
    +     *
    +     * @return  string|null
    +     */
    +    public function resolveStatementColumnAlias($table, $alias)
    +    {
    +        $statementColumnMap = $this->getStatementColumnMap();
    +        if (isset($statementColumnMap[$alias])) {
    +            return $statementColumnMap[$alias];
    +        }
    +
    +        $prefixedAlias = $this->removeTablePrefix($table) . '.' . $alias;
    +        if (isset($statementColumnMap[$prefixedAlias])) {
    +            return $statementColumnMap[$prefixedAlias];
    +        }
    +    }
    +
    +    /**
    +     * Return whether the given alias or statement column name is available in the given table
    +     *
    +     * @param   string  $table
    +     * @param   string  $alias
    +     *
    +     * @return  bool
    +     */
    +    public function validateStatementColumnAssociation($table, $alias)
    +    {
    +        $statementTableMap = $this->getStatementTableMap();
    +        if (isset($statementTableMap[$alias])) {
    +            return $statementTableMap[$alias] === $this->removeTablePrefix($table);
    +        }
    +
    +        $prefixedAlias = $this->removeTablePrefix($table) . '.' . $alias;
    +        return isset($statementTableMap[$prefixedAlias]);
    +    }
    +
    +    /**
    +     * Return whether the given column name or alias of the given table is a valid statement column
    +     *
    +     * @param   string  $table  The table where to look for the column or alias
    +     * @param   string  $name   The column name or alias to check
    +     *
    +     * @return  bool
    +     */
    +    public function hasStatementColumn($table, $name)
    +    {
    +        if (
    +            $this->resolveStatementColumnAlias($table, $name) === null
    +            || !$this->validateStatementColumnAssociation($table, $name)
    +        ) {
    +            return parent::hasStatementColumn($table, $name);
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid statement column and return it or the actual name if it's an alias
    +     *
    +     * @param   string  $table      The table for which to require the column
    +     * @param   string  $name       The name or alias of the column to validate
    +     *
    +     * @return  string              The given column's name
    +     *
    +     * @throws  StatementException  In case the given column is not a statement column
    +     */
    +    public function requireStatementColumn($table, $name)
    +    {
    +        if (($column = $this->resolveStatementColumnAlias($table, $name)) === null) {
    +            return parent::requireStatementColumn($table, $name);
    +        }
    +
    +        if (! $this->validateStatementColumnAssociation($table, $name)) {
    +            throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table);
    +        }
    +
    +        return $column;
    +    }
    +
    +    /**
    +     * Join alias or column $name into $table using $query
    +     *
    +     * Attempts to find a valid table for the given alias or column name and a method labelled join
    +     * to process the actual join logic. If neither of those is found, ProgrammingError will be thrown.
    +     * The method is called with the same parameters but in reversed order.
    +     *
    +     * @param   string              $name       The alias or column name to join into $target
    +     * @param   array|string        $target     The table to join $name into
    +     * @param   RepositoryQUery     $query      The query to apply the JOIN-clause on
    +     *
    +     * @return  string                          The resolved alias or $name
    +     *
    +     * @throws  ProgrammingError                In case no valid table or join-method is found
    +     */
    +    public function joinColumn($name, $target, RepositoryQuery $query)
    +    {
    +        $tableName = $this->findTableName($name);
    +        if (! $tableName) {
    +            throw new ProgrammingError(
    +                'Unable to find a valid table for column "%s" to join into "%s"',
    +                $name,
    +                $this->removeTablePrefix($this->clearTableAlias($target))
    +            );
    +        }
    +
    +        $column = $this->resolveQueryColumnAlias($tableName, $name);
    +
    +        $prefixedTableName = $this->prependTablePrefix($tableName);
    +        if ($query->getQuery()->hasJoinedTable($prefixedTableName)) {
    +            return $column;
    +        }
    +
    +        $joinMethod = 'join' . String::cname($tableName);
    +        if (! method_exists($this, $joinMethod)) {
    +            throw new ProgrammingError(
    +                'Unable to join table "%s" into "%s". Method "%s" not found',
    +                $tableName,
    +                $this->removeTablePrefix($this->clearTableAlias($target)),
    +                $joinMethod
    +            );
    +        }
    +
    +        $this->$joinMethod($query, $target, $name);
    +        return $column;
    +    }
    +
    +    /**
    +     * Return the table name for the given alias or column name
    +     *
    +     * @param   string  $column
    +     *
    +     * @return  string|null         null in case no table is found
    +     */
    +    protected function findTableName($column)
    +    {
    +        $aliasTableMap = $this->getAliasTableMap();
    +        if (isset($aliasTableMap[$column])) {
    +            return $aliasTableMap[$column];
    +        }
    +
    +        // TODO(jom): Elaborate whether it makes sense to throw ProgrammingError
    +        //            instead (duplicate aliases in different tables?)
    +        foreach ($aliasTableMap as $alias => $table) {
    +            if (strpos($alias, '.') !== false) {
    +                list($_, $alias) = explode('.', $column, 2);
    +                if ($alias === $column) {
    +                    return $table;
    +                }
    +            }
    +        }
    +    }
    +}
    diff --git a/library/Icinga/Repository/IniRepository.php b/library/Icinga/Repository/IniRepository.php
    new file mode 100644
    index 000000000..3c73464e5
    --- /dev/null
    +++ b/library/Icinga/Repository/IniRepository.php
    @@ -0,0 +1,186 @@
    +
    + *  
  • Insert, update and delete capabilities
  • + * + */ +abstract class IniRepository extends Repository implements Extensible, Updatable, Reducible +{ + /** + * The datasource being used + * + * @var Config + */ + protected $ds; + + /** + * Create a new INI repository object + * + * @param Config $ds The data source to use + * + * @throws ProgrammingError In case the given data source does not provide a valid key column + */ + public function __construct(Config $ds) + { + parent::__construct($ds); // First! Due to init(). + + if (! $ds->getConfigObject()->getKeyColumn()) { + throw new ProgrammingError('INI repositories require their data source to provide a valid key column'); + } + } + + /** + * Insert the given data for the given target + * + * $data must provide a proper value for the data source's key column. + * + * @param string $target + * @param array $data + * + * @throws StatementException In case the operation has failed + */ + public function insert($target, array $data) + { + $newData = $this->requireStatementColumns($target, $data); + $section = $this->extractSectionName($newData); + + if ($this->ds->hasSection($section)) { + throw new StatementException(t('Cannot insert. Section "%s" does already exist'), $section); + } + + $this->ds->setSection($section, $newData); + + try { + $this->ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to insert. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Update the target with the given data and optionally limit the affected entries by using a filter + * + * @param string $target + * @param array $data + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function update($target, array $data, Filter $filter = null) + { + $newData = $this->requireStatementColumns($target, $data); + $keyColumn = $this->ds->getConfigObject()->getKeyColumn(); + if ($filter === null && isset($newData[$keyColumn])) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + if ($filter !== null) { + $filter = $this->requireFilter($target, $filter); + } + + $newSection = null; + foreach (iterator_to_array($this->ds) as $section => $config) { + if ($filter !== null && !$filter->matches($config)) { + continue; + } + + if ($newSection !== null) { + throw new StatementException( + t('Cannot update. Column "%s" holds a section\'s name which must be unique'), + $keyColumn + ); + } + + foreach ($newData as $column => $value) { + if ($column === $keyColumn) { + $newSection = $value; + } else { + $config->$column = $value; + } + } + + if ($newSection) { + if ($this->ds->hasSection($newSection)) { + throw new StatementException(t('Cannot update. Section "%s" does already exist'), $newSection); + } + + $this->ds->removeSection($section)->setSection($newSection, $config); + } else { + $this->ds->setSection($section, $config); + } + } + + try { + $this->ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to update. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Delete entries in the given target, optionally limiting the affected entries by using a filter + * + * @param string $target + * @param Filter $filter + * + * @throws StatementException In case the operation has failed + */ + public function delete($target, Filter $filter = null) + { + if ($filter !== null) { + $filter = $this->requireFilter($target, $filter); + } + + foreach (iterator_to_array($this->ds) as $section => $config) { + if ($filter === null || $filter->matches($config)) { + $this->ds->removeSection($section); + } + } + + try { + $this->ds->saveIni(); + } catch (Exception $e) { + throw new StatementException(t('Failed to delete. An error occurred: %s'), $e->getMessage()); + } + } + + /** + * Extract and return the section name off of the given $data + * + * @param array $data + * + * @return string + * + * @throws ProgrammingError In case no valid section name is available + */ + protected function extractSectionName(array & $data) + { + $keyColumn = $this->ds->getConfigObject()->getKeyColumn(); + if (! isset($data[$keyColumn])) { + throw new ProgrammingError('$data does not provide a value for key column "%s"', $keyColumn); + } + + $section = $data[$keyColumn]; + unset($data[$keyColumn]); + return $section; + } +} diff --git a/library/Icinga/Repository/Repository.php b/library/Icinga/Repository/Repository.php new file mode 100644 index 000000000..803f4958f --- /dev/null +++ b/library/Icinga/Repository/Repository.php @@ -0,0 +1,861 @@ + + *
  • Concrete implementations need to initialize Repository::$queryColumns
  • + *
  • The datasource passed to a repository must implement the Selectable interface
  • + *
  • The datasource must yield an instance of Queryable when its select() method is called
  • + * + */ +abstract class Repository implements Selectable +{ + /** + * The format to use when converting values of type date_time + */ + const DATETIME_FORMAT = 'd/m/Y g:i A'; + + /** + * The name of this repository + * + * @var string + */ + protected $name; + + /** + * The datasource being used + * + * @var Selectable + */ + protected $ds; + + /** + * The base table name this repository is responsible for + * + * This will be automatically set to the first key of $queryColumns if not explicitly set. + * + * @var string + */ + protected $baseTable; + + /** + * The query columns being provided + * + * This must be initialized by concrete repository implementations, in the following format + *
    
    +     *  array(
    +     *      'baseTable' => array(
    +     *          'column1',
    +     *          'alias1' => 'column2',
    +     *          'alias2' => 'column3'
    +     *      )
    +     *  )
    +     * 
    
    +     *
    +     * @var array
    +     */
    +    protected $queryColumns;
    +
    +    /**
    +     * The columns (or aliases) which are not permitted to be queried. (by design)
    +     *
    +     * @var array   An array of strings
    +     */
    +    protected $filterColumns;
    +
    +    /**
    +     * The default sort rules to be applied on a query
    +     *
    +     * This may be initialized by concrete repository implementations, in the following format
    +     * 
    
    +     *  array(
    +     *      'alias_or_column_name' => array(
    +     *          'order'     => 'asc'
    +     *      ),
    +     *      'alias_or_column_name' => array(
    +     *          'columns'   => array(
    +     *              'once_more_the_alias_or_column_name_as_in_the_parent_key',
    +     *              'an_additional_alias_or_column_name_with_a_specific_direction asc'
    +     *          ),
    +     *          'order'     => 'desc'
    +     *      ),
    +     *      'alias_or_column_name' => array(
    +     *          'columns'   => array('a_different_alias_or_column_name_designated_to_act_as_the_only_sort_column')
    +     *          // Ascendant sort by default
    +     *      )
    +     *  )
    +     * 
    
    +     * Note that it's mandatory to supply the alias name in case there is one.
    +     *
    +     * @var array
    +     */
    +    protected $sortRules;
    +
    +    /**
    +     * The value conversion rules to apply on a query or statement
    +     *
    +     * This may be initialized by concrete repository implementations and describes for which aliases or column
    +     * names what type of conversion is available. For entries, where the key is the alias/column and the value
    +     * is the type identifier, the repository attempts to find a conversion method for the alias/column first and,
    +     * if none is found, then for the type. If an entry only provides a value, which is the alias/column, the
    +     * repository only attempts to find a conversion method for the alias/column. The name of a conversion method
    +     * is expected to be declared using lowerCamelCase. (e.g. user_name will be translated to persistUserName and
    +     * groupname will be translated to retrieveGroupname)
    +     *
    +     * @var array
    +     */
    +    protected $conversionRules;
    +
    +    /**
    +     * An array to map table names to aliases
    +     *
    +     * @var array
    +     */
    +    protected $aliasTableMap;
    +
    +    /**
    +     * A flattened array to map query columns to aliases
    +     *
    +     * @var array
    +     */
    +    protected $aliasColumnMap;
    +
    +    /**
    +     * Create a new repository object
    +     *
    +     * @param   Selectable  $ds     The datasource to use
    +     */
    +    public function __construct(Selectable $ds)
    +    {
    +        $this->ds = $ds;
    +        $this->aliasTableMap = array();
    +        $this->aliasColumnMap = array();
    +
    +        $this->init();
    +    }
    +
    +    /**
    +     * Initialize this repository
    +     *
    +     * Supposed to be overwritten by concrete repository implementations.
    +     */
    +    protected function init()
    +    {
    +
    +    }
    +
    +    /**
    +     * Set this repository's name
    +     *
    +     * @param   string  $name
    +     *
    +     * @return  $this
    +     */
    +    public function setName($name)
    +    {
    +        $this->name = $name;
    +        return $this;
    +    }
    +
    +    /**
    +     * Return this repository's name
    +     *
    +     * In case no name has been explicitly set yet, the class name is returned.
    +     *
    +     * @return  string
    +     */
    +    public function getName()
    +    {
    +        return $this->name ?: __CLASS__;
    +    }
    +
    +    /**
    +     * Return the datasource being used
    +     *
    +     * @return  Selectable
    +     */
    +    public function getDataSource()
    +    {
    +        return $this->ds;
    +    }
    +
    +    /**
    +     * Return the base table name this repository is responsible for
    +     *
    +     * @return  string
    +     *
    +     * @throws  ProgrammingError    In case no base table name has been set and
    +     *                               $this->queryColumns does not provide one either
    +     */
    +    public function getBaseTable()
    +    {
    +        if ($this->baseTable === null) {
    +            $queryColumns = $this->getQueryColumns();
    +            reset($queryColumns);
    +            $this->baseTable = key($queryColumns);
    +            if (is_int($this->baseTable) || !is_array($queryColumns[$this->baseTable])) {
    +                throw new ProgrammingError('"%s" is not a valid base table', $this->baseTable);
    +            }
    +        }
    +
    +        return $this->baseTable;
    +    }
    +
    +    /**
    +     * Return the query columns being provided
    +     *
    +     * Calls $this->initializeQueryColumns() in case $this->queryColumns is null.
    +     *
    +     * @return  array
    +     */
    +    public function getQueryColumns()
    +    {
    +        if ($this->queryColumns === null) {
    +            $this->queryColumns = $this->initializeQueryColumns();
    +        }
    +
    +        return $this->queryColumns;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the query columns lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeQueryColumns()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Return the columns (or aliases) which are not permitted to be queried
    +     *
    +     * Calls $this->initializeFilterColumns() in case $this->filterColumns is null.
    +     *
    +     * @return  array
    +     */
    +    public function getFilterColumns()
    +    {
    +        if ($this->filterColumns === null) {
    +            $this->filterColumns = $this->initializeFilterColumns();
    +        }
    +
    +        return $this->filterColumns;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the filter columns lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeFilterColumns()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Return the default sort rules to be applied on a query
    +     *
    +     * Calls $this->initializeSortRules() in case $this->sortRules is null.
    +     *
    +     * @return  array
    +     */
    +    public function getSortRules()
    +    {
    +        if ($this->sortRules === null) {
    +            $this->sortRules = $this->initializeSortRules();
    +        }
    +
    +        return $this->sortRules;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the sort rules lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeSortRules()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Return the value conversion rules to apply on a query
    +     *
    +     * Calls $this->initializeConversionRules() in case $this->conversionRules is null.
    +     *
    +     * @return  array
    +     */
    +    public function getConversionRules()
    +    {
    +        if ($this->conversionRules === null) {
    +            $this->conversionRules = $this->initializeConversionRules();
    +        }
    +
    +        return $this->conversionRules;
    +    }
    +
    +    /**
    +     * Overwrite this in your repository implementation in case you need to initialize the conversion rules lazily
    +     *
    +     * @return  array
    +     */
    +    protected function initializeConversionRules()
    +    {
    +        return array();
    +    }
    +
    +    /**
    +     * Return an array to map table names to aliases
    +     *
    +     * @return  array
    +     */
    +    protected function getAliasTableMap()
    +    {
    +        if (empty($this->aliasTableMap)) {
    +            $this->initializeAliasMaps();
    +        }
    +
    +        return $this->aliasTableMap;
    +    }
    +
    +    /**
    +     * Return a flattened array to map query columns to aliases
    +     *
    +     * @return  array
    +     */
    +    protected function getAliasColumnMap()
    +    {
    +        if (empty($this->aliasColumnMap)) {
    +            $this->initializeAliasMaps();
    +        }
    +
    +        return $this->aliasColumnMap;
    +    }
    +
    +    /**
    +     * Initialize $this->aliasTableMap and $this->aliasColumnMap
    +     *
    +     * @throws  ProgrammingError    In case $this->queryColumns does not provide any column information
    +     */
    +    protected function initializeAliasMaps()
    +    {
    +        $queryColumns = $this->getQueryColumns();
    +        if (empty($queryColumns)) {
    +            throw new ProgrammingError('Repositories are required to initialize $this->queryColumns first');
    +        }
    +
    +        foreach ($queryColumns as $table => $columns) {
    +            foreach ($columns as $alias => $column) {
    +                if (! is_string($alias)) {
    +                    $key = $column;
    +                } else {
    +                    $key = $alias;
    +                    $column = preg_replace('~\n\s*~', ' ', $column);
    +                }
    +
    +                if (array_key_exists($key, $this->aliasTableMap)) {
    +                    if ($this->aliasTableMap[$key] !== null) {
    +                        $existingTable = $this->aliasTableMap[$key];
    +                        $existingColumn = $this->aliasColumnMap[$key];
    +                        $this->aliasTableMap[$existingTable . '.' . $key] = $existingTable;
    +                        $this->aliasColumnMap[$existingTable . '.' . $key] = $existingColumn;
    +                        $this->aliasTableMap[$key] = null;
    +                        $this->aliasColumnMap[$key] = null;
    +                    }
    +
    +                    $this->aliasTableMap[$table . '.' . $key] = $table;
    +                    $this->aliasColumnMap[$table . '.' . $key] = $column;
    +                } else {
    +                    $this->aliasTableMap[$key] = $table;
    +                    $this->aliasColumnMap[$key] = $column;
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Return a new query for the given columns
    +     *
    +     * @param   array   $columns    The desired columns, if null all columns will be queried
    +     *
    +     * @return  RepositoryQuery
    +     */
    +    public function select(array $columns = null)
    +    {
    +        $query = new RepositoryQuery($this);
    +        $query->from($this->getBaseTable(), $columns);
    +        return $query;
    +    }
    +
    +    /**
    +     * Return whether this repository is capable of converting values for the given table
    +     *
    +     * @param   string  $table
    +     *
    +     * @return  bool
    +     */
    +    public function providesValueConversion($table)
    +    {
    +        $conversionRules = $this->getConversionRules();
    +        return !empty($conversionRules) && isset($conversionRules[$table]);
    +    }
    +
    +    /**
    +     * Convert a value supposed to be transmitted to the data source
    +     *
    +     * @param   string  $table      The table where to persist the value
    +     * @param   string  $name       The alias or column name
    +     * @param   mixed   $value      The value to convert
    +     *
    +     * @return  mixed               If conversion was possible, the converted value, otherwise the unchanged value
    +     */
    +    public function persistColumn($table, $name, $value)
    +    {
    +        $converter = $this->getConverter($table, $name, 'persist');
    +        if ($converter !== null) {
    +            $value = $this->$converter($value);
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Convert a value which was fetched from the data source
    +     *
    +     * @param   string  $table      The table the value has been fetched from
    +     * @param   string  $name       The alias or column name
    +     * @param   mixed   $value      The value to convert
    +     *
    +     * @return  mixed               If conversion was possible, the converted value, otherwise the unchanged value
    +     */
    +    public function retrieveColumn($table, $name, $value)
    +    {
    +        $converter = $this->getConverter($table, $name, 'retrieve');
    +        if ($converter !== null) {
    +            $value = $this->$converter($value);
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Return the name of the conversion method for the given alias or column name and context
    +     *
    +     * @param   string  $table      The datasource's table
    +     * @param   string  $name       The alias or column name for which to return a conversion method
    +     * @param   string  $context    The context of the conversion: persist or retrieve
    +     *
    +     * @return  string
    +     *
    +     * @throws  ProgrammingError    In case a conversion rule is found but not any conversion method
    +     */
    +    protected function getConverter($table, $name, $context)
    +    {
    +        $conversionRules = $this->getConversionRules();
    +        if (! isset($conversionRules[$table])) {
    +            return;
    +        }
    +
    +        $tableRules = $conversionRules[$table];
    +
    +        // Check for a conversion method for the alias/column first
    +        if (array_key_exists($name, $tableRules) || in_array($name, $tableRules)) {
    +            $methodName = $context . join('', array_map('ucfirst', explode('_', $name)));
    +            if (method_exists($this, $methodName)) {
    +                return $methodName;
    +            }
    +        }
    +
    +        // The conversion method for the type is just a fallback, but it is required to exist if defined
    +        if (isset($tableRules[$name])) {
    +            $identifier = join('', array_map('ucfirst', explode('_', $tableRules[$name])));
    +            if (! method_exists($this, $context . $identifier)) {
    +                // Do not throw an error in case at least one conversion method exists
    +                if (! method_exists($this, ($context === 'persist' ? 'retrieve' : 'persist') . $identifier)) {
    +                    throw new ProgrammingError(
    +                        'Cannot find any conversion method for type "%s"'
    +                        . '. Add a proper conversion method or remove the type definition',
    +                        $tableRules[$name]
    +                    );
    +                }
    +
    +                Logger::debug(
    +                    'Conversion method "%s" for type definition "%s" does not exist in repository "%s".',
    +                    $context . $identifier,
    +                    $tableRules[$name],
    +                    $this->getName()
    +                );
    +            } else {
    +                return $context . $identifier;
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Convert a timestamp or DateTime object to a string formatted using static::DATETIME_FORMAT
    +     *
    +     * @param   mixed   $value
    +     *
    +     * @return  string
    +     */
    +    protected function persistDateTime($value)
    +    {
    +        if (is_numeric($value)) {
    +            $value = date(static::DATETIME_FORMAT, $value);
    +        } elseif ($value instanceof DateTime) {
    +            $value = date(static::DATETIME_FORMAT, $value->getTimestamp()); // Using date here, to ignore any timezone
    +        } elseif ($value !== null) {
    +            throw new ProgrammingError(
    +                'Cannot persist value "%s" as type date_time. It\'s not a timestamp or DateTime object',
    +                $value
    +            );
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Convert a string formatted using static::DATETIME_FORMAT to a unix timestamp
    +     *
    +     * @param   string  $value
    +     *
    +     * @return  int
    +     */
    +    protected function retrieveDateTime($value)
    +    {
    +        if (is_numeric($value)) {
    +            $value = (int) $value;
    +        } elseif (is_string($value)) {
    +            $dateTime = DateTime::createFromFormat(static::DATETIME_FORMAT, $value);
    +            if ($dateTime === false) {
    +                Logger::debug(
    +                    'Unable to parse string "%s" as type date_time with format "%s" in repository "%s"',
    +                    $value,
    +                    static::DATETIME_FORMAT,
    +                    $this->getName()
    +                );
    +                $value = null;
    +            } else {
    +                $value = $dateTime->getTimestamp();
    +            }
    +        } elseif ($value !== null) {
    +            throw new ProgrammingError(
    +                'Cannot retrieve value "%s" as type date_time. It\'s not a integer or (numeric) string',
    +                $value
    +            );
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Convert the given array to an comma separated string
    +     *
    +     * @param   array|string    $value
    +     *
    +     * @return  string
    +     */
    +    protected function persistCommaSeparatedString($value)
    +    {
    +        if (is_array($value)) {
    +            $value = join(',', array_map('trim', $value));
    +        } elseif ($value !== null && !is_string($value)) {
    +            throw new ProgrammingError('Cannot persist value "%s" as comma separated string', $value);
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Convert the given comma separated string to an array
    +     *
    +     * @param   string  $value
    +     *
    +     * @return  array
    +     */
    +    protected function retrieveCommaSeparatedString($value)
    +    {
    +        if ($value && is_string($value)) {
    +            $value = String::trimSplit($value);
    +        } elseif ($value !== null) {
    +            throw new ProgrammingError('Cannot retrieve value "%s" as array. It\'s not a string', $value);
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Validate that the requested table exists
    +     *
    +     * @param   string              $table      The table to validate
    +     * @param   RepositoryQuery     $query      An optional query to pass as context
    +     *                                          (unused by the base implementation)
    +     *
    +     * @return  string                          The table's name, may differ from the given one
    +     *
    +     * @throws  ProgrammingError                In case the given table does not exist
    +     */
    +    public function requireTable($table, RepositoryQuery $query = null)
    +    {
    +        $queryColumns = $this->getQueryColumns();
    +        if (! isset($queryColumns[$table])) {
    +            throw new ProgrammingError('Table "%s" not found', $table);
    +        }
    +
    +        return $table;
    +    }
    +
    +    /**
    +     * Recurse the given filter, require each column for the given table and convert all values
    +     *
    +     * @param   string              $table      The table being filtered
    +     * @param   Filter              $filter     The filter to recurse
    +     * @param   RepositoryQuery     $query      An optional query to pass as context
    +     *                                          (Directly passed through to $this->requireFilterColumn)
    +     * @param   bool                $clone      Whether to clone $filter first
    +     *
    +     * @return  Filter                          The udpated filter
    +     */
    +    public function requireFilter($table, Filter $filter, RepositoryQuery $query = null, $clone = true)
    +    {
    +        if ($clone) {
    +            $filter = clone $filter;
    +        }
    +
    +        if ($filter->isExpression()) {
    +            $column = $filter->getColumn();
    +            $filter->setColumn($this->requireFilterColumn($table, $column, $query));
    +            $filter->setExpression($this->persistColumn($table, $column, $filter->getExpression()));
    +        } elseif ($filter->isChain()) {
    +            foreach ($filter->filters() as $chainOrExpression) {
    +                $this->requireFilter($table, $chainOrExpression, $query, false);
    +            }
    +        }
    +
    +        return $filter;
    +    }
    +
    +    /**
    +     * Return this repository's query columns of the given table mapped to their respective aliases
    +     *
    +     * @param   string  $table
    +     *
    +     * @return  array
    +     *
    +     * @throws  ProgrammingError    In case $table does not exist
    +     */
    +    public function requireAllQueryColumns($table)
    +    {
    +        $queryColumns = $this->getQueryColumns();
    +        if (! array_key_exists($table, $queryColumns)) {
    +            throw new ProgrammingError('Table name "%s" not found', $table);
    +        }
    +
    +        $filterColumns = $this->getFilterColumns();
    +        $columns = array();
    +        foreach ($queryColumns[$table] as $alias => $column) {
    +            if (! in_array(is_string($alias) ? $alias : $column, $filterColumns)) {
    +                $columns[$alias] = $column;
    +            }
    +        }
    +
    +        return $columns;
    +    }
    +
    +    /**
    +     * Return the query column name for the given alias or null in case the alias does not exist
    +     *
    +     * @param   string  $table
    +     * @param   string  $alias
    +     *
    +     * @return  string|null
    +     */
    +    public function resolveQueryColumnAlias($table, $alias)
    +    {
    +        $aliasColumnMap = $this->getAliasColumnMap();
    +        if (isset($aliasColumnMap[$alias])) {
    +            return $aliasColumnMap[$alias];
    +        }
    +
    +        $prefixedAlias = $table . '.' . $alias;
    +        if (isset($aliasColumnMap[$prefixedAlias])) {
    +            return $aliasColumnMap[$prefixedAlias];
    +        }
    +    }
    +
    +    /**
    +     * Return whether the given alias or query column name is available in the given table
    +     *
    +     * @param   string  $table
    +     * @param   string  $alias
    +     *
    +     * @return  bool
    +     */
    +    public function validateQueryColumnAssociation($table, $alias)
    +    {
    +        $aliasTableMap = $this->getAliasTableMap();
    +        if (isset($aliasTableMap[$alias])) {
    +            return $aliasTableMap[$alias] === $table;
    +        }
    +
    +        $prefixedAlias = $table . '.' . $alias;
    +        return isset($aliasTableMap[$prefixedAlias]);
    +    }
    +
    +    /**
    +     * Return whether the given column name or alias is a valid query column
    +     *
    +     * @param   string  $table  The table where to look for the column or alias
    +     * @param   string  $name   The column name or alias to check
    +     *
    +     * @return  bool
    +     */
    +    public function hasQueryColumn($table, $name)
    +    {
    +        if (in_array($name, $this->getFilterColumns())) {
    +            return false;
    +        }
    +
    +        return $this->resolveQueryColumnAlias($table, $name) !== null
    +            && $this->validateQueryColumnAssociation($table, $name);
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid query target and return it or the actual name if it's an alias
    +     *
    +     * @param   string              $table  The table where to look for the column or alias
    +     * @param   string              $name   The name or alias of the column to validate
    +     * @param   RepositoryQuery     $query  An optional query to pass as context (unused by the base implementation)
    +     *
    +     * @return  string                      The given column's name
    +     *
    +     * @throws  QueryException              In case the given column is not a valid query column
    +     */
    +    public function requireQueryColumn($table, $name, RepositoryQuery $query = null)
    +    {
    +        if (in_array($name, $this->getFilterColumns())) {
    +            throw new QueryException(t('Filter column "%s" cannot be queried'), $name);
    +        }
    +
    +        if (($column = $this->resolveQueryColumnAlias($table, $name)) === null) {
    +            throw new QueryException(t('Query column "%s" not found'), $name);
    +        }
    +
    +        if (! $this->validateQueryColumnAssociation($table, $name)) {
    +            throw new QueryException(t('Query column "%s" not found in table "%s"'), $name, $table);
    +        }
    +
    +        return $column;
    +    }
    +
    +    /**
    +     * Return whether the given column name or alias is a valid filter column
    +     *
    +     * @param   string  $table  The table where to look for the column or alias
    +     * @param   string  $name   The column name or alias to check
    +     *
    +     * @return  bool
    +     */
    +    public function hasFilterColumn($table, $name)
    +    {
    +        return $this->resolveQueryColumnAlias($table, $name) !== null
    +            && $this->validateQueryColumnAssociation($table, $name);
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid filter target and return it or the actual name if it's an alias
    +     *
    +     * @param   string              $table  The table where to look for the column or alias
    +     * @param   string              $name   The name or alias of the column to validate
    +     * @param   RepositoryQuery     $query  An optional query to pass as context (unused by the base implementation)
    +     *
    +     * @return  string                      The given column's name
    +     *
    +     * @throws  QueryException              In case the given column is not a valid filter column
    +     */
    +    public function requireFilterColumn($table, $name, RepositoryQuery $query = null)
    +    {
    +        if (($column = $this->resolveQueryColumnAlias($table, $name)) === null) {
    +            throw new QueryException(t('Filter column "%s" not found'), $name);
    +        }
    +
    +        if (! $this->validateQueryColumnAssociation($table, $name)) {
    +            throw new QueryException(t('Filter column "%s" not found in table "%s"'), $name, $table);
    +        }
    +
    +        return $column;
    +    }
    +
    +    /**
    +     * Return whether the given column name or alias of the given table is a valid statement column
    +     *
    +     * @param   string  $table  The table where to look for the column or alias
    +     * @param   string  $name   The column name or alias to check
    +     *
    +     * @return  bool
    +     */
    +    public function hasStatementColumn($table, $name)
    +    {
    +        return $this->hasQueryColumn($table, $name);
    +    }
    +
    +    /**
    +     * Validate that the given column is a valid statement column and return it or the actual name if it's an alias
    +     *
    +     * @param   string  $table      The table for which to require the column
    +     * @param   string  $name       The name or alias of the column to validate
    +     *
    +     * @return  string              The given column's name
    +     *
    +     * @throws  StatementException  In case the given column is not a statement column
    +     */
    +    public function requireStatementColumn($table, $name)
    +    {
    +        if (in_array($name, $this->filterColumns)) {
    +            throw new StatementException('Filter column "%s" cannot be referenced in a statement', $name);
    +        }
    +
    +        if (($column = $this->resolveQueryColumnAlias($table, $name)) === null) {
    +            throw new StatementException('Statement column "%s" not found', $name);
    +        }
    +
    +        if (! $this->validateQueryColumnAssociation($table, $name)) {
    +            throw new StatementException('Statement column "%s" not found in table "%s"', $name, $table);
    +        }
    +
    +        return $column;
    +    }
    +
    +    /**
    +     * Resolve the given aliases or column names of the given table supposed to be persisted and convert their values
    +     *
    +     * @param   string  $table
    +     * @param   array   $data
    +     *
    +     * @return  array
    +     */
    +    public function requireStatementColumns($table, array $data)
    +    {
    +        $resolved = array();
    +        foreach ($data as $alias => $value) {
    +            $resolved[$this->requireStatementColumn($table, $alias)] = $this->persistColumn($table, $alias, $value);
    +        }
    +
    +        return $resolved;
    +    }
    +}
    diff --git a/library/Icinga/Repository/RepositoryQuery.php b/library/Icinga/Repository/RepositoryQuery.php
    new file mode 100644
    index 000000000..c5e9ac9ed
    --- /dev/null
    +++ b/library/Icinga/Repository/RepositoryQuery.php
    @@ -0,0 +1,589 @@
    +repository = $repository;
    +    }
    +
    +    /**
    +     * Return the real query being used
    +     *
    +     * @return  QueryInterface
    +     */
    +    public function getQuery()
    +    {
    +        return $this->query;
    +    }
    +
    +    /**
    +     * Set where to fetch which columns
    +     *
    +     * This notifies the repository about each desired query column.
    +     *
    +     * @param   mixed   $target     The target from which to fetch the columns
    +     * @param   array   $columns    If null or an empty array, all columns will be fetched
    +     *
    +     * @return  $this
    +     */
    +    public function from($target, array $columns = null)
    +    {
    +        $target = $this->repository->requireTable($target, $this);
    +        $this->query = $this->repository->getDataSource()->select()->from($target);
    +        $this->query->columns($this->prepareQueryColumns($target, $columns));
    +        $this->target = $target;
    +        return $this;
    +    }
    +
    +    /**
    +     * Return the columns to fetch
    +     *
    +     * @return  array
    +     */
    +    public function getColumns()
    +    {
    +        return $this->query->getColumns();
    +    }
    +
    +    /**
    +     * Set which columns to fetch
    +     *
    +     * This notifies the repository about each desired query column.
    +     *
    +     * @param   array   $columns    If null or an empty array, all columns will be fetched
    +     *
    +     * @return  $this
    +     */
    +    public function columns(array $columns)
    +    {
    +        $this->query->columns($this->prepareQueryColumns($this->target, $columns));
    +        return $this;
    +    }
    +
    +    /**
    +     * Resolve the given columns supposed to be fetched
    +     *
    +     * This notifies the repository about each desired query column.
    +     *
    +     * @param   mixed   $target             The target where to look for each column
    +     * @param   array   $desiredColumns     Pass null or an empty array to require all query columns
    +     *
    +     * @return  array                       The desired columns indexed by their respective alias
    +     */
    +    protected function prepareQueryColumns($target, array $desiredColumns = null)
    +    {
    +        if (empty($desiredColumns)) {
    +            $columns = $this->repository->requireAllQueryColumns($target);
    +        } else {
    +            $columns = array();
    +            foreach ($desiredColumns as $customAlias => $columnAlias) {
    +                $resolvedColumn = $this->repository->requireQueryColumn($target, $columnAlias, $this);
    +                if ($resolvedColumn !== $columnAlias) {
    +                    $columns[is_string($customAlias) ? $customAlias : $columnAlias] = $resolvedColumn;
    +                } elseif (is_string($customAlias)) {
    +                    $columns[$customAlias] = $columnAlias;
    +                } else {
    +                    $columns[] = $columnAlias;
    +                }
    +            }
    +        }
    +
    +        return $columns;
    +    }
    +
    +    /**
    +     * Filter this query using the given column and value
    +     *
    +     * This notifies the repository about the required filter column.
    +     *
    +     * @param   string  $column
    +     * @param   mixed   $value
    +     *
    +     * @return  $this
    +     */
    +    public function where($column, $value = null)
    +    {
    +        $this->query->where(
    +            $this->repository->requireFilterColumn($this->target, $column, $this),
    +            $this->repository->persistColumn($this->target, $column, $value)
    +        );
    +        return $this;
    +    }
    +
    +    /**
    +     * Add an additional filter expression to this query
    +     *
    +     * This notifies the repository about each required filter column.
    +     *
    +     * @param   Filter  $filter
    +     *
    +     * @return  $this
    +     */
    +    public function applyFilter(Filter $filter)
    +    {
    +        return $this->addFilter($filter);
    +    }
    +
    +    /**
    +     * Set a filter for this query
    +     *
    +     * This notifies the repository about each required filter column.
    +     *
    +     * @param   Filter  $filter
    +     *
    +     * @return  $this
    +     */
    +    public function setFilter(Filter $filter)
    +    {
    +        $this->query->setFilter($this->repository->requireFilter($this->target, $filter, $this));
    +        return $this;
    +    }
    +
    +    /**
    +     * Add an additional filter expression to this query
    +     *
    +     * This notifies the repository about each required filter column.
    +     *
    +     * @param   Filter  $filter
    +     *
    +     * @return  $this
    +     */
    +    public function addFilter(Filter $filter)
    +    {
    +        $this->query->addFilter($this->repository->requireFilter($this->target, $filter, $this));
    +        return $this;
    +    }
    +
    +    /**
    +     * Return the current filter
    +     *
    +     * @return  Filter
    +     */
    +    public function getFilter()
    +    {
    +        return $this->query->getFilter();
    +    }
    +
    +    /**
    +     * Add a sort rule for this query
    +     *
    +     * If called without a specific column, the repository's defaul sort rules will be applied.
    +     * This notifies the repository about each column being required as filter column.
    +     *
    +     * @param   string  $field      The name of the column by which to sort the query's result
    +     * @param   string  $direction  The direction to use when sorting (asc or desc, default is asc)
    +     *
    +     * @return  $this
    +     */
    +    public function order($field = null, $direction = null)
    +    {
    +        $sortRules = $this->repository->getSortRules();
    +        if ($field === null) {
    +            // Use first available sort rule as default
    +            if (empty($sortRules)) {
    +                // Return early in case of no sort defaults and no given $field
    +                return $this;
    +            }
    +
    +            $sortColumns = reset($sortRules);
    +            if (! array_key_exists('columns', $sortColumns)) {
    +                $sortColumns['columns'] = array(key($sortRules));
    +            }
    +            if ($direction !== null || !array_key_exists('order', $sortColumns)) {
    +                $sortColumns['order'] = $direction ?: static::SORT_ASC;
    +            }
    +        } elseif (array_key_exists($field, $sortRules)) {
    +            $sortColumns = $sortRules[$field];
    +            if (! array_key_exists('columns', $sortColumns)) {
    +                $sortColumns['columns'] = array($field);
    +            }
    +            if ($direction !== null || !array_key_exists('order', $sortColumns)) {
    +                $sortColumns['order'] = $direction ?: static::SORT_ASC;
    +            }
    +        } else {
    +            $sortColumns = array(
    +                'columns'   => array($field),
    +                'order'     => $direction
    +            );
    +        };
    +
    +        $baseDirection = strtoupper($sortColumns['order']) === static::SORT_DESC ? static::SORT_DESC : static::SORT_ASC;
    +
    +        foreach ($sortColumns['columns'] as $column) {
    +            list($column, $specificDirection) = $this->splitOrder($column);
    +
    +            try {
    +                $this->query->order(
    +                    $this->repository->requireFilterColumn($this->target, $column, $this),
    +                    $specificDirection ?: $baseDirection
    +                    // I would have liked the following solution, but hey, a coder should be allowed to produce crap...
    +                    // $specificDirection && (! $direction || $column !== $field) ? $specificDirection : $baseDirection
    +                );
    +            } catch (QueryException $_) {
    +                Logger::info('Cannot order by column "%s" in repository "%s"', $column, $this->repository->getName());
    +            }
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Extract and return the name and direction of the given sort column definition
    +     *
    +     * @param   string  $field
    +     *
    +     * @return  array               An array of two items: $columnName, $direction
    +     */
    +    protected function splitOrder($field)
    +    {
    +        $columnAndDirection = explode(' ', $field, 2);
    +        if (count($columnAndDirection) === 1) {
    +            $column = $field;
    +            $direction = null;
    +        } else {
    +            $column = $columnAndDirection[0];
    +            $direction = strtoupper($columnAndDirection[1]) === static::SORT_DESC
    +                ? static::SORT_DESC
    +                : static::SORT_ASC;
    +        }
    +
    +        return array($column, $direction);
    +    }
    +
    +    /**
    +     * Return whether any sort rules were applied to this query
    +     *
    +     * @return  bool
    +     */
    +    public function hasOrder()
    +    {
    +        return $this->query->hasOrder();
    +    }
    +
    +    /**
    +     * Return the sort rules applied to this query
    +     *
    +     * @return  array
    +     */
    +    public function getOrder()
    +    {
    +        return $this->query->getOrder();
    +    }
    +
    +    /**
    +     * Limit this query's results
    +     *
    +     * @param   int     $count      When to stop returning results
    +     * @param   int     $offset     When to start returning results
    +     *
    +     * @return  $this
    +     */
    +    public function limit($count = null, $offset = null)
    +    {
    +        $this->query->limit($count, $offset);
    +        return $this;
    +    }
    +
    +    /**
    +     * Return whether this query does not return all available entries from its result
    +     *
    +     * @return  bool
    +     */
    +    public function hasLimit()
    +    {
    +        return $this->query->hasLimit();
    +    }
    +
    +    /**
    +     * Return the limit when to stop returning results
    +     *
    +     * @return  int
    +     */
    +    public function getLimit()
    +    {
    +        return $this->query->getLimit();
    +    }
    +
    +    /**
    +     * Return whether this query does not start returning results at the very first entry
    +     *
    +     * @return  bool
    +     */
    +    public function hasOffset()
    +    {
    +        return $this->query->hasOffset();
    +    }
    +
    +    /**
    +     * Return the offset when to start returning results
    +     *
    +     * @return  int
    +     */
    +    public function getOffset()
    +    {
    +        return $this->query->getOffset();
    +    }
    +
    +    /**
    +     * Fetch and return the first column of this query's first row
    +     *
    +     * @return  mixed|false     False in case of no result
    +     */
    +    public function fetchOne()
    +    {
    +        if (! $this->hasOrder()) {
    +            $this->order();
    +        }
    +
    +        $result = $this->query->fetchOne();
    +        if ($result !== false && $this->repository->providesValueConversion($this->target)) {
    +            $columns = $this->getColumns();
    +            $column = isset($columns[0]) ? $columns[0] : key($columns);
    +            return $this->repository->retrieveColumn($this->target, $column, $result);
    +        }
    +
    +        return $result;
    +    }
    +
    +    /**
    +     * Fetch and return the first row of this query's result
    +     *
    +     * @return  object|false    False in case of no result
    +     */
    +    public function fetchRow()
    +    {
    +        if (! $this->hasOrder()) {
    +            $this->order();
    +        }
    +
    +        $result = $this->query->fetchRow();
    +        if ($result !== false && $this->repository->providesValueConversion($this->target)) {
    +            foreach ($this->getColumns() as $alias => $column) {
    +                if (! is_string($alias)) {
    +                    $alias = $column;
    +                }
    +
    +                $result->$alias = $this->repository->retrieveColumn($this->target, $alias, $result->$alias);
    +            }
    +        }
    +
    +        return $result;
    +    }
    +
    +    /**
    +     * Fetch and return the first column of all rows of the result set as an array
    +     *
    +     * @return  array
    +     */
    +    public function fetchColumn()
    +    {
    +        if (! $this->hasOrder()) {
    +            $this->order();
    +        }
    +
    +        $results = $this->query->fetchColumn();
    +        if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
    +            $columns = $this->getColumns();
    +            $aliases = array_keys($columns);
    +            $column = is_int($aliases[0]) ? $columns[0] : $aliases[0];
    +            foreach ($results as & $value) {
    +                $value = $this->repository->retrieveColumn($this->target, $column, $value);
    +            }
    +        }
    +
    +        return $results;
    +    }
    +
    +    /**
    +     * Fetch and return all rows of this query's result set as an array of key-value pairs
    +     *
    +     * The first column is the key, the second column is the value.
    +     *
    +     * @return  array
    +     */
    +    public function fetchPairs()
    +    {
    +        if (! $this->hasOrder()) {
    +            $this->order();
    +        }
    +
    +        $results = $this->query->fetchPairs();
    +        if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
    +            $columns = $this->getColumns();
    +            $aliases = array_keys($columns);
    +            $newResults = array();
    +            foreach ($results as $colOneValue => $colTwoValue) {
    +                $colOne = $aliases[0] !== 0 ? $aliases[0] : $columns[0];
    +                $colTwo = count($aliases) < 2 ? $colOne : ($aliases[1] !== 1 ? $aliases[1] : $columns[1]);
    +                $colOneValue = $this->repository->retrieveColumn($this->target, $colOne, $colOneValue);
    +                $newResults[$colOneValue] = $this->repository->retrieveColumn($this->target, $colTwo, $colTwoValue);
    +            }
    +
    +            $results = $newResults;
    +        }
    +
    +        return $results;
    +    }
    +
    +    /**
    +     * Fetch and return all results of this query
    +     *
    +     * @return  array
    +     */
    +    public function fetchAll()
    +    {
    +        if (! $this->hasOrder()) {
    +            $this->order();
    +        }
    +
    +        $results = $this->query->fetchAll();
    +        if (! empty($results) && $this->repository->providesValueConversion($this->target)) {
    +            $columns = $this->getColumns();
    +            foreach ($results as $row) {
    +                foreach ($columns as $alias => $column) {
    +                    if (! is_string($alias)) {
    +                        $alias = $column;
    +                    }
    +
    +                    $row->$alias = $this->repository->retrieveColumn($this->target, $alias, $row->$alias);
    +                }
    +            }
    +        }
    +
    +        return $results;
    +    }
    +
    +    /**
    +     * Count all results of this query
    +     *
    +     * @return  int
    +     */
    +    public function count()
    +    {
    +        return $this->query->count();
    +    }
    +
    +    /**
    +     * Start or rewind the iteration
    +     */
    +    public function rewind()
    +    {
    +        if ($this->iterator === null) {
    +            if (! $this->hasOrder()) {
    +                $this->order();
    +            }
    +
    +            $iterator = $this->repository->getDataSource()->query($this->query);
    +            if ($iterator instanceof IteratorAggregate) {
    +                $this->iterator = $iterator->getIterator();
    +            } else {
    +                $this->iterator = $iterator;
    +            }
    +        }
    +
    +        $this->iterator->rewind();
    +        Benchmark::measure('Query result iteration started');
    +    }
    +
    +    /**
    +     * Fetch and return the current row of this query's result
    +     *
    +     * @return  object
    +     */
    +    public function current()
    +    {
    +        $row = $this->iterator->current();
    +        if ($this->repository->providesValueConversion($this->target)) {
    +            foreach ($this->getColumns() as $alias => $column) {
    +                if (! is_string($alias)) {
    +                    $alias = $column;
    +                }
    +
    +                $row->$alias = $this->repository->retrieveColumn($this->target, $alias, $row->$alias);
    +            }
    +        }
    +
    +        return $row;
    +    }
    +
    +    /**
    +     * Return whether the current row of this query's result is valid
    +     *
    +     * @return  bool
    +     */
    +    public function valid()
    +    {
    +        if (! $this->iterator->valid()) {
    +            Benchmark::measure('Query result iteration finished');
    +            return false;
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Return the key for the current row of this query's result
    +     *
    +     * @return  mixed
    +     */
    +    public function key()
    +    {
    +        return $this->iterator->key();
    +    }
    +
    +    /**
    +     * Advance to the next row of this query's result
    +     */
    +    public function next()
    +    {
    +        $this->iterator->next();
    +    }
    +}
    diff --git a/library/Icinga/User.php b/library/Icinga/User.php
    index f9df0d664..c322a04d0 100644
    --- a/library/Icinga/User.php
    +++ b/library/Icinga/User.php
    @@ -426,7 +426,7 @@ class User
             // matches
             $any = strpos($requiredPermission, '*');
             foreach ($this->permissions as $grantedPermission) {
    -            if ($any !== false && strpos($grantedPermission, '*') === false) {
    +            if ($any !== false) {
                     $wildcard = $any;
                 } else {
                     // If the permit contains a wildcard, grant the permission if it's related to the permit
    diff --git a/library/Icinga/Web/Controller/AuthBackendController.php b/library/Icinga/Web/Controller/AuthBackendController.php
    new file mode 100644
    index 000000000..43ed78fd7
    --- /dev/null
    +++ b/library/Icinga/Web/Controller/AuthBackendController.php
    @@ -0,0 +1,198 @@
    +hasPermission('config/authentication/users/show')) {
    +            $this->redirectNow('user/list');
    +        } elseif ($this->hasPermission('config/authentication/groups/show')) {
    +            $this->redirectNow('group/list');
    +        } elseif ($this->hasPermission('config/authentication/roles/show')) {
    +            $this->redirectNow('role/list');
    +        } else {
    +            throw new SecurityException($this->translate('No permission for authentication configuration'));
    +        }
    +    }
    +
    +    /**
    +     * Return all user backends implementing the given interface
    +     *
    +     * @param   string  $interface      The class path of the interface, or null if no interface check should be made
    +     *
    +     * @return  array
    +     */
    +    protected function loadUserBackends($interface = null)
    +    {
    +        $backends = array();
    +        foreach (Config::app('authentication') as $backendName => $backendConfig) {
    +            $candidate = UserBackend::create($backendName, $backendConfig);
    +            if (! $interface || $candidate instanceof $interface) {
    +                $backends[] = $candidate;
    +            }
    +        }
    +
    +        return $backends;
    +    }
    +
    +    /**
    +     * Return the given user backend or the first match in order
    +     *
    +     * @param   string  $name           The name of the backend, or null in case the first match should be returned
    +     * @param   string  $interface      The interface the backend should implement, no interface check if null
    +     *
    +     * @return  UserBackendInterface
    +     *
    +     * @throws  Zend_Controller_Action_Exception    In case the given backend name is invalid
    +     */
    +    protected function getUserBackend($name = null, $interface = 'Icinga\Data\Selectable')
    +    {
    +        if ($name !== null) {
    +            $config = Config::app('authentication');
    +            if (! $config->hasSection($name)) {
    +                $this->httpNotFound(sprintf($this->translate('Authentication backend "%s" not found'), $name));
    +            } else {
    +                $backend = UserBackend::create($name, $config->getSection($name));
    +                if ($interface && !$backend instanceof $interface) {
    +                    $interfaceParts = explode('\\', strtolower($interface));
    +                    throw new Zend_Controller_Action_Exception(
    +                        sprintf(
    +                            $this->translate('Authentication backend "%s" is not %s'),
    +                            $name,
    +                            array_pop($interfaceParts)
    +                        ),
    +                        400
    +                    );
    +                }
    +            }
    +        } else {
    +            $backends = $this->loadUserBackends($interface);
    +            $backend = array_shift($backends);
    +        }
    +
    +        return $backend;
    +    }
    +
    +    /**
    +     * Return all user group backends implementing the given interface
    +     *
    +     * @param   string  $interface      The class path of the interface, or null if no interface check should be made
    +     *
    +     * @return  array
    +     */
    +    protected function loadUserGroupBackends($interface = null)
    +    {
    +        $backends = array();
    +        foreach (Config::app('groups') as $backendName => $backendConfig) {
    +            $candidate = UserGroupBackend::create($backendName, $backendConfig);
    +            if (! $interface || $candidate instanceof $interface) {
    +                $backends[] = $candidate;
    +            }
    +        }
    +
    +        return $backends;
    +    }
    +
    +    /**
    +     * Return the given user group backend or the first match in order
    +     *
    +     * @param   string  $name           The name of the backend, or null in case the first match should be returned
    +     * @param   string  $interface      The interface the backend should implement, no interface check if null
    +     *
    +     * @return  UserGroupBackendInterface
    +     *
    +     * @throws  Zend_Controller_Action_Exception    In case the given backend name is invalid
    +     */
    +    protected function getUserGroupBackend($name = null, $interface = 'Icinga\Data\Selectable')
    +    {
    +        if ($name !== null) {
    +            $config = Config::app('groups');
    +            if (! $config->hasSection($name)) {
    +                $this->httpNotFound(sprintf($this->translate('User group backend "%s" not found'), $name));
    +            } else {
    +                $backend = UserGroupBackend::create($name, $config->getSection($name));
    +                if ($interface && !$backend instanceof $interface) {
    +                    $interfaceParts = explode('\\', strtolower($interface));
    +                    throw new Zend_Controller_Action_Exception(
    +                        sprintf(
    +                            $this->translate('User group backend "%s" is not %s'),
    +                            $name,
    +                            array_pop($interfaceParts)
    +                        ),
    +                        400
    +                    );
    +                }
    +            }
    +        } else {
    +            $backends = $this->loadUserGroupBackends($interface);
    +            $backend = array_shift($backends);
    +        }
    +
    +        return $backend;
    +    }
    +
    +    /**
    +     * Create the tabs to list users and groups
    +     */
    +    protected function createListTabs()
    +    {
    +        $tabs = $this->getTabs();
    +
    +        if ($this->hasPermission('config/authentication/users/show')) {
    +            $tabs->add(
    +                'user/list',
    +                array(
    +                    'title'     => $this->translate('List users of authentication backends'),
    +                    'label'     => $this->translate('Users'),
    +                    'icon'      => 'user',
    +                    'url'       => 'user/list'
    +                )
    +            );
    +        }
    +
    +        if ($this->hasPermission('config/authentication/groups/show')) {
    +            $tabs->add(
    +                'group/list',
    +                array(
    +                    'title'     => $this->translate('List groups of user group backends'),
    +                    'label'     => $this->translate('Groups'),
    +                    'icon'      => 'users',
    +                    'url'       => 'group/list'
    +                )
    +            );
    +        }
    +
    +        if ($this->hasPermission('config/authentication/roles/show')) {
    +            $tabs->add(
    +                'role/list',
    +                array(
    +                    'title' => $this->translate(
    +                        'Configure roles to permit or restrict users and groups accessing Icinga Web 2'
    +                    ),
    +                    'label' => $this->translate('Roles'),
    +                    'url'   => 'role/list'
    +                )
    +            );
    +        }
    +
    +        return $tabs;
    +    }
    +}
    diff --git a/library/Icinga/Web/Form/ErrorLabeller.php b/library/Icinga/Web/Form/ErrorLabeller.php
    index 455f5f81e..f66260149 100644
    --- a/library/Icinga/Web/Form/ErrorLabeller.php
    +++ b/library/Icinga/Web/Form/ErrorLabeller.php
    @@ -39,7 +39,7 @@ class ErrorLabeller extends Zend_Translate_Adapter
     
         protected function createMessages($element)
         {
    -        $label = $element->getLabel();
    +        $label = $element->getLabel() ?: $element->getName();
     
             return array(
                 Zend_Validate_NotEmpty::IS_EMPTY    => sprintf(t('%s is required and must not be empty'), $label),
    diff --git a/library/Icinga/Web/Menu.php b/library/Icinga/Web/Menu.php
    index 476d73879..63d92fc9f 100644
    --- a/library/Icinga/Web/Menu.php
    +++ b/library/Icinga/Web/Menu.php
    @@ -233,40 +233,50 @@ class Menu implements RecursiveIterator
                 ));
     
                 $section = $this->add(t('System'), array(
    -                'icon'     => 'wrench',
    -                'priority' => 200,
    +                'icon'     => 'services',
    +                'priority' => 700,
                     'renderer' => 'ProblemMenuItemRenderer'
                 ));
    -            $section->add(t('Configuration'), array(
    +            if (Logger::writesToFile()) {
    +                $section->add(t('Application Log'), array(
    +                    'url'      => 'list/applicationlog',
    +                    'priority' => 710
    +                ));
    +            }
    +
    +            $section = $this->add(t('Configuration'), array(
    +                'icon'          => 'wrench',
    +                'permission'    => 'config/*',
    +                'priority'      => 800
    +            ));
    +            $section->add(t('Application'), array(
                     'url'           => 'config',
                     'permission'    => 'config/application/*',
    -                'priority'      => 300
    +                'priority'      => 810
    +            ));
    +            $section->add(t('Authentication'), array(
    +                'url'           => 'user',
    +                'permission'    => 'config/authentication/*',
    +                'priority'      => 820
                 ));
                 $section->add(t('Modules'), array(
                     'url'           => 'config/modules',
                     'permission'    => 'config/modules',
    -                'priority'      => 400
    +                'priority'      => 890
                 ));
     
    -            if (Logger::writesToFile()) {
    -                $section->add(t('Application Log'), array(
    -                    'url'      => 'list/applicationlog',
    -                    'priority' => 500
    -                ));
    -            }
    -
                 $section = $this->add($auth->getUser()->getUsername(), array(
                     'icon'     => 'user',
    -                'priority' => 600
    +                'priority' => 900
                 ));
                 $section->add(t('Preferences'), array(
                     'url'      => 'preference',
    -                'priority' => 601
    +                'priority' => 910
                 ));
     
                 $section->add(t('Logout'), array(
                     'url'      => 'authentication/logout',
    -                'priority' => 700,
    +                'priority' => 990,
                     'renderer' => 'ForeignMenuItemRenderer'
                 ));
             }
    diff --git a/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php
    index 5d85a4428..3f2b71cc3 100644
    --- a/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php
    +++ b/library/Icinga/Web/Paginator/Adapter/QueryAdapter.php
    @@ -4,44 +4,64 @@
     namespace Icinga\Web\Paginator\Adapter;
     
     use Zend_Paginator_Adapter_Interface;
    -
    -/**
    - * @see Zend_Paginator_Adapter_Interface
    - */
    +use Icinga\Data\QueryInterface;
     
     class QueryAdapter implements Zend_Paginator_Adapter_Interface
     {
         /**
    -     * Array
    +     * The query being paginated
          *
    -     * @var array
    +     * @var QueryInterface
          */
    -    protected $query = null;
    +    protected $query;
     
         /**
          * Item count
          *
    -     * @var integer
    +     * @var int
          */
    -    protected $count = null;
    +    protected $count;
     
         /**
    -     * Constructor.
    +     * Create a new QueryAdapter
          *
    -     * @param array $query Query to paginate
    +     * @param   QueryInterface  $query      The query to paginate
          */
    -    // TODO: This might be ready for (QueryInterface $query)
    -    public function __construct($query)
    +    public function __construct(QueryInterface $query)
         {
    -        $this->query = $query;
    +        $this->setQuery($query);
         }
     
         /**
    -     * Returns an array of items for a page.
    +     * Set the query to paginate
          *
    -     * @param  integer $offset Page offset
    -     * @param  integer $itemCountPerPage Number of items per page
    -     * @return array
    +     * @param   QueryInterface  $query
    +     *
    +     * @return  $this
    +     */
    +    public function setQuery(QueryInterface $query)
    +    {
    +        $this->query = $query;
    +        return $this;
    +    }
    +
    +    /**
    +     * Return the query being paginated
    +     *
    +     * @return  QueryInterface
    +     */
    +    public function getQuery()
    +    {
    +        return $this->query;
    +    }
    +
    +    /**
    +     * Fetch and return the rows in the given range of the query result
    +     *
    +     * @param   int     $offset             Page offset
    +     * @param   int     $itemCountPerPage   Number of items per page
    +     *
    +     * @return  array
          */
         public function getItems($offset, $itemCountPerPage)
         {
    @@ -49,15 +69,16 @@ class QueryAdapter implements Zend_Paginator_Adapter_Interface
         }
     
         /**
    -     * Returns the total number of items in the query result.
    +     * Return the total number of items in the query result
          *
    -     * @return integer
    +     * @return  int
          */
         public function count()
         {
             if ($this->count === null) {
                 $this->count = $this->query->count();
             }
    +
             return $this->count;
         }
     }
    diff --git a/modules/doc/configuration.php b/modules/doc/configuration.php
    index 031a62e64..392009798 100644
    --- a/modules/doc/configuration.php
    +++ b/modules/doc/configuration.php
    @@ -7,7 +7,7 @@ $section = $this->menuSection($this->translate('Documentation'), array(
         'title'    => 'Documentation',
         'icon'     => 'book',
         'url'      => 'doc',
    -    'priority' => 190
    +    'priority' => 700
     ));
     
     $section->add('Icinga Web 2', array(
    @@ -18,7 +18,7 @@ $section->add('Module documentations', array(
     ));
     $section->add($this->translate('Developer - Style'), array(
         'url' => 'doc/style/guide',
    -    'priority' => 200,
    +    'priority' => 790
     ));
     
     $this->provideSearchUrl($this->translate('Doc'), 'doc/search', -10);
    diff --git a/modules/monitoring/configuration.php b/modules/monitoring/configuration.php
    index fd3ee334d..66b42aa20 100644
    --- a/modules/monitoring/configuration.php
    +++ b/modules/monitoring/configuration.php
    @@ -208,7 +208,7 @@ $section->add($this->translate('Alert Summary'), array(
     $section = $this->menuSection($this->translate('System'));
     $section->add($this->translate('Monitoring Health'), array(
         'url'      => 'monitoring/process/info',
    -    'priority' => 120,
    +    'priority' => 720,
         'renderer'  => 'Icinga\Module\Monitoring\Web\Menu\BackendAvailabilityMenuItemRenderer'
     ));
     
    diff --git a/modules/monitoring/library/Monitoring/Backend/Ido/Query/IdoQuery.php b/modules/monitoring/library/Monitoring/Backend/Ido/Query/IdoQuery.php
    index 9371399bc..0b436d7bf 100644
    --- a/modules/monitoring/library/Monitoring/Backend/Ido/Query/IdoQuery.php
    +++ b/modules/monitoring/library/Monitoring/Backend/Ido/Query/IdoQuery.php
    @@ -415,21 +415,11 @@ abstract class IdoQuery extends DbQuery
             } elseif ($dbType === 'pgsql') {
                 $this->initializeForPostgres();
             }
    -        $this->dbSelect();
    +        $this->joinBaseTables();
             $this->select->columns($this->columns);
    -        //$this->joinBaseTables();
             $this->prepareAliasIndexes();
         }
     
    -    protected function dbSelect()
    -    {
    -        if ($this->select === null) {
    -            $this->select = $this->db->select();
    -            $this->joinBaseTables();
    -        }
    -        return clone $this->select;
    -    }
    -
         /**
          * Join the base tables for this query
          */
    diff --git a/modules/monitoring/library/Monitoring/Command/Transport/CommandTransport.php b/modules/monitoring/library/Monitoring/Command/Transport/CommandTransport.php
    index a9839565b..f39b0b4c3 100644
    --- a/modules/monitoring/library/Monitoring/Command/Transport/CommandTransport.php
    +++ b/modules/monitoring/library/Monitoring/Command/Transport/CommandTransport.php
    @@ -108,7 +108,8 @@ abstract class CommandTransport
          */
         public static function first()
         {
    -        $config = self::getConfig()->current();
    -        return self::fromConfig($config);
    +        $config = self::getConfig();
    +        $config->rewind();
    +        return self::fromConfig($config->current());
         }
     }
    diff --git a/modules/monitoring/library/Monitoring/DataView/DataView.php b/modules/monitoring/library/Monitoring/DataView/DataView.php
    index 69d8631c8..ba628f31c 100644
    --- a/modules/monitoring/library/Monitoring/DataView/DataView.php
    +++ b/modules/monitoring/library/Monitoring/DataView/DataView.php
    @@ -3,7 +3,6 @@
     
     namespace Icinga\Module\Monitoring\DataView;
     
    -use ArrayIterator;
     use IteratorAggregate;
     use Icinga\Data\QueryInterface;
     use Icinga\Data\Filter\Filter;
    @@ -13,6 +12,7 @@ use Icinga\Data\ConnectionInterface;
     use Icinga\Exception\QueryException;
     use Icinga\Web\Request;
     use Icinga\Web\Url;
    +use Icinga\Module\Monitoring\Backend\Ido\Query\IdoQuery;
     use Icinga\Module\Monitoring\Backend\MonitoringBackend;
     
     /**
    @@ -23,7 +23,7 @@ abstract class DataView implements QueryInterface, IteratorAggregate
         /**
          * The query used to populate the view
          *
    -     * @var QueryInterface
    +     * @var IdoQuery
          */
         protected $query;
     
    @@ -61,11 +61,11 @@ abstract class DataView implements QueryInterface, IteratorAggregate
         /**
          * Return a iterator for all rows of the result set
          *
    -     * @return  ArrayIterator
    +     * @return  IdoQuery
          */
         public function getIterator()
         {
    -        return new ArrayIterator($this->fetchAll());
    +        return $this->getQuery();
         }
     
         /**
    @@ -481,15 +481,13 @@ abstract class DataView implements QueryInterface, IteratorAggregate
         }
     
         /**
    -     * Fetch a column of all rows of the result set as an array
    -     *
    -     * @param   int $columnIndex Index of the column to fetch
    +     * Fetch the first column of all rows of the result set as an array
          *
          * @return  array
          */
    -    public function fetchColumn($columnIndex = 0)
    +    public function fetchColumn()
         {
    -        return $this->getQuery()->fetchColumn($columnIndex);
    +        return $this->getQuery()->fetchColumn();
         }
     
         /**
    diff --git a/modules/setup/application/forms/AdminAccountPage.php b/modules/setup/application/forms/AdminAccountPage.php
    index b123b142d..a7bd8fc6c 100644
    --- a/modules/setup/application/forms/AdminAccountPage.php
    +++ b/modules/setup/application/forms/AdminAccountPage.php
    @@ -8,8 +8,8 @@ use LogicException;
     use Icinga\Web\Form;
     use Icinga\Data\ConfigObject;
     use Icinga\Data\ResourceFactory;
    -use Icinga\Authentication\Backend\DbUserBackend;
    -use Icinga\Authentication\Backend\LdapUserBackend;
    +use Icinga\Authentication\User\DbUserBackend;
    +use Icinga\Authentication\User\LdapUserBackend;
     
     /**
      * Wizard page to define the initial administrative account
    @@ -268,13 +268,8 @@ class AdminAccountPage extends Form
             if ($this->backendConfig['backend'] === 'db') {
                 $backend = new DbUserBackend(ResourceFactory::createResource(new ConfigObject($this->resourceConfig)));
             } elseif ($this->backendConfig['backend'] === 'ldap') {
    -            $backend = new LdapUserBackend(
    -                ResourceFactory::createResource(new ConfigObject($this->resourceConfig)),
    -                $this->backendConfig['user_class'],
    -                $this->backendConfig['user_name_attribute'],
    -                $this->backendConfig['base_dn'],
    -                $this->backendConfig['filter']
    -            );
    +            $backend = new LdapUserBackend(ResourceFactory::createResource(new ConfigObject($this->resourceConfig)));
    +            $backend->setConfig($this->backendConfig);
             } else {
                 throw new LogicException(
                     sprintf(
    @@ -285,10 +280,8 @@ class AdminAccountPage extends Form
             }
     
             try {
    -            $users = $backend->listUsers();
    -            natsort ($users);
    -            return $users;
    -        } catch (Exception $e) {
    +            return $backend->select(array('user_name'))->fetchColumn();
    +        } catch (Exception $_) {
                 // No need to handle anything special here. Error means no users found.
                 return array();
             }
    diff --git a/modules/setup/application/forms/AuthBackendPage.php b/modules/setup/application/forms/AuthBackendPage.php
    index f3bce41fa..bb68792a6 100644
    --- a/modules/setup/application/forms/AuthBackendPage.php
    +++ b/modules/setup/application/forms/AuthBackendPage.php
    @@ -4,9 +4,9 @@
     namespace Icinga\Module\Setup\Forms;
     
     use Icinga\Web\Form;
    -use Icinga\Forms\Config\Authentication\DbBackendForm;
    -use Icinga\Forms\Config\Authentication\LdapBackendForm;
    -use Icinga\Forms\Config\Authentication\ExternalBackendForm;
    +use Icinga\Forms\Config\UserBackend\DbBackendForm;
    +use Icinga\Forms\Config\UserBackend\LdapBackendForm;
    +use Icinga\Forms\Config\UserBackend\ExternalBackendForm;
     use Icinga\Data\ConfigObject;
     
     /**
    @@ -105,7 +105,7 @@ class AuthBackendPage extends Form
             }
     
             if (false === isset($data['skip_validation']) || $data['skip_validation'] == 0) {
    -            if ($this->config['type'] === 'ldap' && false === LdapBackendForm::isValidAuthenticationBackend($this)) {
    +            if ($this->config['type'] === 'ldap' && false === LdapBackendForm::isValidUserBackend($this)) {
                     $this->addSkipValidationCheckbox();
                     return false;
                 }
    diff --git a/modules/setup/library/Setup/Steps/AuthenticationStep.php b/modules/setup/library/Setup/Steps/AuthenticationStep.php
    index 9de0c9867..58d9d68d7 100644
    --- a/modules/setup/library/Setup/Steps/AuthenticationStep.php
    +++ b/modules/setup/library/Setup/Steps/AuthenticationStep.php
    @@ -7,7 +7,7 @@ use Exception;
     use Icinga\Application\Config;
     use Icinga\Data\ConfigObject;
     use Icinga\Data\ResourceFactory;
    -use Icinga\Authentication\Backend\DbUserBackend;
    +use Icinga\Authentication\User\DbUserBackend;
     use Icinga\Module\Setup\Step;
     
     class AuthenticationStep extends Step
    @@ -88,11 +88,12 @@ class AuthenticationStep extends Step
                     ResourceFactory::createResource(new ConfigObject($this->data['adminAccountData']['resourceConfig']))
                 );
     
    -            if (array_search($this->data['adminAccountData']['username'], $backend->listUsers()) === false) {
    -                $backend->addUser(
    -                    $this->data['adminAccountData']['username'],
    -                    $this->data['adminAccountData']['password']
    -                );
    +            if ($backend->select()->where('user_name', $this->data['adminAccountData']['username'])->count() === 0) {
    +                $backend->insert('user', array(
    +                    'user_name' => $this->data['adminAccountData']['username'],
    +                    'password'  => $this->data['adminAccountData']['password'],
    +                    'is_active' => true
    +                ));
                 }
             } catch (Exception $e) {
                 $this->dbError = $e;
    diff --git a/public/css/icinga/main-content.less b/public/css/icinga/main-content.less
    index b442723cb..2b0e2e50d 100644
    --- a/public/css/icinga/main-content.less
    +++ b/public/css/icinga/main-content.less
    @@ -41,6 +41,10 @@ img.icon {
       background-position: 1em center;
     }
     
    +#notifications > li.info {
    +  background-color: @colorFormNotificationInfo;
    +}
    +
     #notifications > li.warning {
       background-color: @colorWarningHandled;
     }
    @@ -202,3 +206,164 @@ table.benchmark {
       border: 1px solid lightgrey;
       background-color: #fbfcc5;
     }
    +
    +div.content.users {
    +  table.user-list {
    +    th.user-remove {
    +      width: 8em;
    +      padding-right: 0.5em;
    +      text-align: right;
    +    }
    +
    +    td.user-remove {
    +      text-align: right;
    +    }
    +  }
    +
    +  p {
    +    margin-top: 0;
    +  }
    +
    +  a.user-add {
    +    display: block;
    +    margin-top: 1em;
    +  }
    +}
    +
    +div.controls div.user-header {
    +  border-bottom: 2px solid @colorPetrol;
    +  margin-bottom: 1em;
    +
    +  .user-name {
    +    display: inline-block;
    +    margin: 0 0 0.3em;
    +    font-size: 2em;
    +  }
    +
    +  .user-state, .user-created, .user-modified {
    +    margin: 0 0 0.2em;
    +    font-size: 0.8em;
    +  }
    +}
    +
    +div.content.memberships {
    +  table.membership-list {
    +    th.membership-cancel {
    +      width: 8em;
    +      padding-right: 0.5em;
    +      text-align: right;
    +    }
    +
    +    td.membership-cancel {
    +      text-align: right;
    +
    +      form button.link-like {
    +        color: inherit;
    +      }
    +    }
    +  }
    +
    +  p {
    +    margin-top: 0;
    +  }
    +
    +  a.membership-create {
    +    display: block;
    +    margin-top: 1em;
    +  }
    +}
    +
    +div.content.groups {
    +  table.group-list {
    +    th.group-remove {
    +      width: 8em;
    +      padding-right: 0.5em;
    +      text-align: right;
    +    }
    +
    +    td.group-remove {
    +      text-align: right;
    +    }
    +  }
    +
    +  p {
    +    margin-top: 0;
    +  }
    +
    +  a.group-add {
    +    display: block;
    +    margin-top: 1em;
    +  }
    +}
    +
    +div.controls div.group-header {
    +  border-bottom: 2px solid @colorPetrol;
    +  margin-bottom: 1em;
    +
    +  .group-name {
    +    display: inline-block;
    +    margin: 0 0 0.3em;
    +    font-size: 2em;
    +  }
    +
    +  .group-parent, .group-created, .group-modified {
    +    margin: 0 0 0.2em;
    +    font-size: 0.8em;
    +  }
    +}
    +
    +div.content.members {
    +  table.member-list {
    +    th.member-remove {
    +      width: 8em;
    +      padding-right: 0.5em;
    +      text-align: right;
    +    }
    +
    +    td.member-remove {
    +      text-align: right;
    +
    +      form button.link-like {
    +        color: inherit;
    +      }
    +    }
    +  }
    +
    +  p {
    +    margin-top: 0;
    +  }
    +
    +  a.member-add {
    +    display: block;
    +    margin-top: 1em;
    +  }
    +}
    +
    +form.backend-selection {
    +  float: right;
    +
    +  div.element {
    +    margin: 0;
    +
    +    label {
    +      width: auto;
    +      margin-right: 0.5em;
    +    }
    +
    +    select {
    +      width: 11.5em;
    +      margin-left: 0;
    +    }
    +  }
    +}
    +
    +table.usergroupbackend-list {
    +  th.backend-remove {
    +    width: 8em;
    +    text-align: right;
    +  }
    +
    +  td.backend-remove {
    +    text-align: right;
    +  }
    +}
    \ No newline at end of file
    diff --git a/public/js/icinga/loader.js b/public/js/icinga/loader.js
    index 99667ac1c..082b41e87 100644
    --- a/public/js/icinga/loader.js
    +++ b/public/js/icinga/loader.js
    @@ -310,7 +310,7 @@
                     } else {
     
                         if (req.$target.attr('id') === 'col2') { // TODO: multicol
    -                        if ($('#col1').data('icingaUrl') === redirect) {
    +                        if ($('#col1').data('icingaUrl').split('?')[0] === redirect.split('?')[0]) {
                                 icinga.ui.layout1col();
                                 req.$target = $('#col1');
                                 delete(this.requests['col2']);
    diff --git a/test/php/application/forms/Config/Authentication/DbBackendFormTest.php b/test/php/application/forms/Config/UserBackend/DbBackendFormTest.php
    similarity index 72%
    rename from test/php/application/forms/Config/Authentication/DbBackendFormTest.php
    rename to test/php/application/forms/Config/UserBackend/DbBackendFormTest.php
    index fb93f6050..d58ff8a33 100644
    --- a/test/php/application/forms/Config/Authentication/DbBackendFormTest.php
    +++ b/test/php/application/forms/Config/UserBackend/DbBackendFormTest.php
    @@ -1,7 +1,7 @@
     setUpResourceFactoryMock();
    -        Mockery::mock('overload:Icinga\Authentication\Backend\DbUserBackend')
    -            ->shouldReceive('count')
    +        Mockery::mock('overload:Icinga\Authentication\User\DbUserBackend')
    +            ->shouldReceive('select->where->count')
                 ->andReturn(2);
     
             // Passing array(null) is required to make Mockery call the constructor...
    -        $form = Mockery::mock('Icinga\Forms\Config\Authentication\DbBackendForm[getView]', array(null));
    +        $form = Mockery::mock('Icinga\Forms\Config\UserBackend\DbBackendForm[getView]', array(null));
             $form->shouldReceive('getView->escape')
                 ->with(Mockery::type('string'))
                 ->andReturnUsing(function ($s) { return $s; });
    @@ -41,8 +41,8 @@ class DbBackendFormTest extends BaseTestCase
             $form->populate(array('resource' => 'test_db_backend'));
     
             $this->assertTrue(
    -            DbBackendForm::isValidAuthenticationBackend($form),
    -            'DbBackendForm claims that a valid authentication backend with users is not valid'
    +            DbBackendForm::isValidUserBackend($form),
    +            'DbBackendForm claims that a valid user backend with users is not valid'
             );
         }
     
    @@ -53,12 +53,12 @@ class DbBackendFormTest extends BaseTestCase
         public function testInvalidBackendIsNotValid()
         {
             $this->setUpResourceFactoryMock();
    -        Mockery::mock('overload:Icinga\Authentication\Backend\DbUserBackend')
    +        Mockery::mock('overload:Icinga\Authentication\User\DbUserBackend')
                 ->shouldReceive('count')
                 ->andReturn(0);
     
             // Passing array(null) is required to make Mockery call the constructor...
    -        $form = Mockery::mock('Icinga\Forms\Config\Authentication\DbBackendForm[getView]', array(null));
    +        $form = Mockery::mock('Icinga\Forms\Config\UserBackend\DbBackendForm[getView]', array(null));
             $form->shouldReceive('getView->escape')
                 ->with(Mockery::type('string'))
                 ->andReturnUsing(function ($s) { return $s; });
    @@ -67,8 +67,8 @@ class DbBackendFormTest extends BaseTestCase
             $form->populate(array('resource' => 'test_db_backend'));
     
             $this->assertFalse(
    -            DbBackendForm::isValidAuthenticationBackend($form),
    -            'DbBackendForm claims that an invalid authentication backend without users is valid'
    +            DbBackendForm::isValidUserBackend($form),
    +            'DbBackendForm claims that an invalid user backend without users is valid'
             );
         }
     
    diff --git a/test/php/application/forms/Config/Authentication/LdapBackendFormTest.php b/test/php/application/forms/Config/UserBackend/LdapBackendFormTest.php
    similarity index 74%
    rename from test/php/application/forms/Config/Authentication/LdapBackendFormTest.php
    rename to test/php/application/forms/Config/UserBackend/LdapBackendFormTest.php
    index 56ea08987..f7373a7ae 100644
    --- a/test/php/application/forms/Config/Authentication/LdapBackendFormTest.php
    +++ b/test/php/application/forms/Config/UserBackend/LdapBackendFormTest.php
    @@ -1,7 +1,7 @@
     setUpResourceFactoryMock();
    -        Mockery::mock('overload:Icinga\Authentication\Backend\LdapUserBackend')
    -            ->shouldReceive('assertAuthenticationPossible')->andReturnNull();
    +        Mockery::mock('overload:Icinga\Authentication\User\LdapUserBackend')
    +            ->shouldReceive('assertAuthenticationPossible')->andReturnNull()
    +            ->shouldReceive('setConfig')->andReturnNull();
     
             // Passing array(null) is required to make Mockery call the constructor...
    -        $form = Mockery::mock('Icinga\Forms\Config\Authentication\LdapBackendForm[getView]', array(null));
    +        $form = Mockery::mock('Icinga\Forms\Config\UserBackend\LdapBackendForm[getView]', array(null));
             $form->shouldReceive('getView->escape')
                 ->with(Mockery::type('string'))
                 ->andReturnUsing(function ($s) { return $s; });
    @@ -41,8 +42,8 @@ class LdapBackendFormTest extends BaseTestCase
             $form->populate(array('resource' => 'test_ldap_backend'));
     
             $this->assertTrue(
    -            LdapBackendForm::isValidAuthenticationBackend($form),
    -            'LdapBackendForm claims that a valid authentication backend with users is not valid'
    +            LdapBackendForm::isValidUserBackend($form),
    +            'LdapBackendForm claims that a valid user backend with users is not valid'
             );
         }
     
    @@ -53,11 +54,11 @@ class LdapBackendFormTest extends BaseTestCase
         public function testInvalidBackendIsNotValid()
         {
             $this->setUpResourceFactoryMock();
    -        Mockery::mock('overload:Icinga\Authentication\Backend\LdapUserBackend')
    +        Mockery::mock('overload:Icinga\Authentication\User\LdapUserBackend')
                 ->shouldReceive('assertAuthenticationPossible')->andThrow(new AuthenticationException);
     
             // Passing array(null) is required to make Mockery call the constructor...
    -        $form = Mockery::mock('Icinga\Forms\Config\Authentication\LdapBackendForm[getView]', array(null));
    +        $form = Mockery::mock('Icinga\Forms\Config\UserBackend\LdapBackendForm[getView]', array(null));
             $form->shouldReceive('getView->escape')
                 ->with(Mockery::type('string'))
                 ->andReturnUsing(function ($s) { return $s; });
    @@ -66,8 +67,8 @@ class LdapBackendFormTest extends BaseTestCase
             $form->populate(array('resource' => 'test_ldap_backend'));
     
             $this->assertFalse(
    -            LdapBackendForm::isValidAuthenticationBackend($form),
    -            'LdapBackendForm claims that an invalid authentication backend without users is valid'
    +            LdapBackendForm::isValidUserBackend($form),
    +            'LdapBackendForm claims that an invalid user backend without users is valid'
             );
         }
     
    diff --git a/test/php/application/forms/Config/AuthenticationBackendReorderFormTest.php b/test/php/application/forms/Config/UserBackendReorderFormTest.php
    similarity index 64%
    rename from test/php/application/forms/Config/AuthenticationBackendReorderFormTest.php
    rename to test/php/application/forms/Config/UserBackendReorderFormTest.php
    index 23563f31b..240d578be 100644
    --- a/test/php/application/forms/Config/AuthenticationBackendReorderFormTest.php
    +++ b/test/php/application/forms/Config/UserBackendReorderFormTest.php
    @@ -5,10 +5,10 @@ namespace Tests\Icinga\Forms\Config;
     
     use Icinga\Test\BaseTestCase;
     use Icinga\Application\Config;
    -use Icinga\Forms\Config\AuthenticationBackendConfigForm;
    -use Icinga\Forms\Config\AuthenticationBackendReorderForm;
    +use Icinga\Forms\Config\UserBackendConfigForm;
    +use Icinga\Forms\Config\UserBackendReorderForm;
     
    -class AuthenticationBackendConfigFormWithoutSave extends AuthenticationBackendConfigForm
    +class UserBackendConfigFormWithoutSave extends UserBackendConfigForm
     {
         public static $newConfig;
     
    @@ -19,11 +19,11 @@ class AuthenticationBackendConfigFormWithoutSave extends AuthenticationBackendCo
         }
     }
     
    -class AuthenticationBackendReorderFormProvidingConfigFormWithoutSave extends AuthenticationBackendReorderForm
    +class UserBackendReorderFormProvidingConfigFormWithoutSave extends UserBackendReorderForm
     {
         public function getConfigForm()
         {
    -        $form = new AuthenticationBackendConfigFormWithoutSave();
    +        $form = new UserBackendConfigFormWithoutSave();
             $form->setIniConfig($this->config);
             return $form;
         }
    @@ -45,7 +45,7 @@ class AuthenticationBackendReorderFormTest extends BaseTestCase
                 ->shouldReceive('isPost')->andReturn(true)
                 ->shouldReceive('getPost')->andReturn(array('backend_newpos' => 'test3|1'));
     
    -        $form = new AuthenticationBackendReorderFormProvidingConfigFormWithoutSave();
    +        $form = new UserBackendReorderFormProvidingConfigFormWithoutSave();
             $form->setIniConfig($config);
             $form->setTokenDisabled();
             $form->setUidDisabled();
    @@ -53,8 +53,8 @@ class AuthenticationBackendReorderFormTest extends BaseTestCase
     
             $this->assertEquals(
                 array('test1', 'test3', 'test2'),
    -            AuthenticationBackendConfigFormWithoutSave::$newConfig->keys(),
    -            'Moving elements with AuthenticationBackendReorderForm does not seem to properly work'
    +            UserBackendConfigFormWithoutSave::$newConfig->keys(),
    +            'Moving elements with UserBackendReorderForm does not seem to properly work'
             );
         }
     }
    diff --git a/test/php/library/Icinga/Data/ConfigObjectTest.php b/test/php/library/Icinga/Data/ConfigObjectTest.php
    index eabdf80cf..820d17b83 100644
    --- a/test/php/library/Icinga/Data/ConfigObjectTest.php
    +++ b/test/php/library/Icinga/Data/ConfigObjectTest.php
    @@ -60,14 +60,6 @@ class ConfigObjectTest extends BaseTestCase
             );
         }
     
    -    public function testWhetherConfigObjectsAreCountable()
    -    {
    -        $config = new ConfigObject(array('a' => 'b', 'c' => array('d' => 'e')));
    -
    -        $this->assertInstanceOf('Countable', $config, 'ConfigObject objects do not implement interface `Countable\'');
    -        $this->assertEquals(2, count($config), 'ConfigObject objects do not count properties and sections correctly');
    -    }
    -
         public function testWhetherConfigObjectsAreTraversable()
         {
             $config = new ConfigObject(array('a' => 'b', 'c' => 'd'));
    @@ -124,7 +116,7 @@ class ConfigObjectTest extends BaseTestCase
         }
     
         /**
    -     * @expectedException LogicException
    +     * @expectedException \Icinga\Exception\ProgrammingError
          */
         public function testWhetherItIsNotPossibleToAppendProperties()
         {
    @@ -142,9 +134,6 @@ class ConfigObjectTest extends BaseTestCase
             $this->assertFalse(isset($config->c), 'ConfigObjects do not allow to unset sections');
         }
     
    -    /**
    -     * @depends testWhetherConfigObjectsAreCountable
    -     */
         public function testWhetherOneCanCheckIfAConfigObjectHasAnyPropertiesOrSections()
         {
             $config = new ConfigObject();
    diff --git a/test/php/library/Icinga/Protocol/Ldap/ConnectionTest.php b/test/php/library/Icinga/Protocol/Ldap/ConnectionTest.php
    deleted file mode 100644
    index 182004703..000000000
    --- a/test/php/library/Icinga/Protocol/Ldap/ConnectionTest.php
    +++ /dev/null
    @@ -1,258 +0,0 @@
    -getAttributesMock;
    -        }
    -
    -        function ldap_start_tls()
    -        {
    -            global $self;
    -            $self->startTlsCalled = true;
    -        }
    -
    -        function ldap_set_option($ds, $option, $value)
    -        {
    -            global $self;
    -            $self->activatedOptions[$option] = $value;
    -            return true;
    -        }
    -
    -        function ldap_set($ds, $option)
    -        {
    -            global $self;
    -            $self->activatedOptions[] = $option;
    -        }
    -
    -        function ldap_control_paged_result()
    -        {
    -            global $self;
    -            $self->pagedResultsCalled = true;
    -            return true;
    -        }
    -
    -        function ldap_control_paged_result_response()
    -        {
    -            return true;
    -        }
    -
    -        function ldap_get_dn()
    -        {
    -            return NULL;
    -        }
    -
    -        function ldap_free_result()
    -        {
    -            return NULL;
    -        }
    -    }
    -
    -    private function node(&$element, $name)
    -    {
    -        $element['count']++;
    -        $element[$name] = array('count' => 0);
    -        $element[] = $name;
    -    }
    -
    -    private function addEntry(&$element, $name, $entry)
    -    {
    -        $element[$name]['count']++;
    -        $element[$name][] = $entry;
    -    }
    -
    -    private function mockQuery()
    -    {
    -        return Mockery::mock('overload:Icinga\Protocol\Ldap\Query')
    -                ->shouldReceive(array(
    -                    'from' => Mockery::self(),
    -                    'create' => array('count' => 1),
    -                    'listFields' => array('count' => 1),
    -                    'getLimit' => 1,
    -                    'hasOffset' => false,
    -                    'hasBase' => false,
    -                    'getSortColumns' => array(),
    -                    'getUsePagedResults' => true
    -                ));
    -    }
    -
    -    private function connectionFetchAll()
    -    {
    -        $this->mockQuery();
    -        $this->connection->connect();
    -        $this->connection->fetchAll(Mockery::self());
    -    }
    -
    -    public function setUp()
    -    {
    -        $this->pagedResultsCalled = false;
    -        $this->startTlsCalled = false;
    -        $this->activatedOptions = array();
    -
    -        $this->mockLdapFunctions();
    -
    -        $config = new ConfigObject(
    -            array(
    -                'hostname' => 'localhost',
    -                'root_dn'  => 'dc=example,dc=com',
    -                'bind_dn'  => 'cn=user,ou=users,dc=example,dc=com',
    -                'bind_pw'  => '***'
    -            )
    -        );
    -        $this->connection = new Connection($config);
    -
    -        $caps = array('count' => 0);
    -        $this->node($caps, 'defaultNamingContext');
    -        $this->node($caps, 'namingContexts');
    -        $this->node($caps, 'supportedCapabilities');
    -        $this->node($caps, 'supportedControl');
    -        $this->node($caps, 'supportedLDAPVersion');
    -        $this->node($caps, 'supportedExtension');
    -        $this->getAttributesMock = $caps;
    -    }
    -
    -    public function testUsePageControlWhenAnnounced()
    -    {
    -        if (version_compare(PHP_VERSION, '5.4.0') < 0) {
    -            $this->markTestSkipped('Page control needs at least PHP_VERSION 5.4.0');
    -        }
    -
    -        $this->addEntry($this->getAttributesMock, 'supportedControl', Capability::LDAP_PAGED_RESULT_OID_STRING);
    -        $this->connectionFetchAll();
    -
    -        // see ticket #7993
    -        $this->assertEquals(true, $this->pagedResultsCalled, "Use paged result when capability is present.");
    -    }
    -
    -    public function testDontUsePagecontrolWhenNotAnnounced()
    -    {
    -        if (version_compare(PHP_VERSION, '5.4.0') < 0) {
    -            $this->markTestSkipped('Page control needs at least PHP_VERSION 5.4.0');
    -        }
    -        $this->connectionFetchAll();
    -
    -        // see ticket #8490
    -        $this->assertEquals(false, $this->pagedResultsCalled, "Don't use paged result when capability is not announced.");
    -    }
    -
    -    public function testUseLdapV2WhenAnnounced()
    -    {
    -         // TODO: Test turned off, see other TODO in Ldap/Connection.
    -         $this->markTestSkipped('LdapV2 currently turned off.');
    -
    -         $this->addEntry($this->getAttributesMock, 'supportedLDAPVersion', 2);
    -         $this->connectionFetchAll();
    -
    -         $this->assertArrayHasKey(LDAP_OPT_PROTOCOL_VERSION, $this->activatedOptions, "LDAP version must be set");
    -         $this->assertEquals($this->activatedOptions[LDAP_OPT_PROTOCOL_VERSION], 2);
    -    }
    -
    -    public function testUseLdapV3WhenAnnounced()
    -    {
    -        $this->addEntry($this->getAttributesMock, 'supportedLDAPVersion', 3);
    -        $this->connectionFetchAll();
    -
    -        $this->assertArrayHasKey(LDAP_OPT_PROTOCOL_VERSION, $this->activatedOptions, "LDAP version must be set");
    -        $this->assertEquals($this->activatedOptions[LDAP_OPT_PROTOCOL_VERSION], 3, "LDAPv3 must be active");
    -    }
    -
    -    public function testDefaultSettings()
    -    {
    -        $this->connectionFetchAll();
    -
    -        $this->assertArrayHasKey(LDAP_OPT_PROTOCOL_VERSION, $this->activatedOptions, "LDAP version must be set");
    -        $this->assertEquals($this->activatedOptions[LDAP_OPT_PROTOCOL_VERSION], 3, "LDAPv3 must be active");
    -
    -        $this->assertArrayHasKey(LDAP_OPT_REFERRALS, $this->activatedOptions, "Following referrals must be turned off");
    -        $this->assertEquals($this->activatedOptions[LDAP_OPT_REFERRALS], 0, "Following referrals must be turned off");
    -    }
    -
    -
    -    public function testActiveDirectoryDiscovery()
    -    {
    -        $this->addEntry($this->getAttributesMock, 'supportedCapabilities', Capability::LDAP_CAP_ACTIVE_DIRECTORY_OID);
    -        $this->connectionFetchAll();
    -
    -        $this->assertEquals(true, $this->connection->getCapabilities()->hasAdOid(),
    -            "Server with LDAP_CAP_ACTIVE_DIRECTORY_OID must be recognized as Active Directory.");
    -    }
    -
    -    public function testDefaultNamingContext()
    -    {
    -        $this->addEntry($this->getAttributesMock, 'defaultNamingContext', 'dn=default,dn=contex');
    -        $this->connectionFetchAll();
    -
    -        $this->assertEquals('dn=default,dn=contex', $this->connection->getCapabilities()->getDefaultNamingContext(),
    -            'Default naming context must be correctly recognized.');
    -    }
    -
    -    public function testDefaultNamingContextFallback()
    -    {
    -        $this->addEntry($this->getAttributesMock, 'namingContexts', 'dn=some,dn=other,dn=context');
    -        $this->addEntry($this->getAttributesMock, 'namingContexts', 'dn=default,dn=context');
    -        $this->connectionFetchAll();
    -
    -        $this->assertEquals('dn=some,dn=other,dn=context', $this->connection->getCapabilities()->getDefaultNamingContext(),
    -            'If defaultNamingContext is missing, the connection must fallback to first namingContext.');
    -    }
    -}
    diff --git a/test/php/library/Icinga/Protocol/Ldap/QueryTest.php b/test/php/library/Icinga/Protocol/Ldap/QueryTest.php
    index 589dc8f36..44e47df78 100644
    --- a/test/php/library/Icinga/Protocol/Ldap/QueryTest.php
    +++ b/test/php/library/Icinga/Protocol/Ldap/QueryTest.php
    @@ -36,51 +36,11 @@ class QueryTest extends BaseTestCase
             return $select;
         }
     
    -    public function testLimit()
    -    {
    -        $select = $this->prepareSelect();
    -        $this->assertEquals(10, $select->getLimit());
    -        $this->assertEquals(4, $select->getOffset());
    -    }
    -
    -    public function testHasLimit()
    -    {
    -        $select = $this->emptySelect();
    -        $this->assertFalse($select->hasLimit());
    -        $select = $this->prepareSelect();
    -        $this->assertTrue($select->hasLimit());
    -    }
    -
    -    public function testHasOffset()
    -    {
    -        $select = $this->emptySelect();
    -        $this->assertFalse($select->hasOffset());
    -        $select = $this->prepareSelect();
    -        $this->assertTrue($select->hasOffset());
    -    }
    -
    -    public function testGetLimit()
    -    {
    -        $select = $this->prepareSelect();
    -        $this->assertEquals(10, $select->getLimit());
    -    }
    -
    -    public function testGetOffset()
    -    {
    -        $select = $this->prepareSelect();
    -        $this->assertEquals(10, $select->getLimit());
    -    }
    -
         public function testFetchTree()
         {
             $this->markTestIncomplete('testFetchTree is not implemented yet - requires real LDAP');
         }
     
    -    public function testFrom()
    -    {
    -        return $this->testListFields();
    -    }
    -
         public function testWhere()
         {
             $this->markTestIncomplete('testWhere is not implemented yet');
    @@ -88,30 +48,13 @@ class QueryTest extends BaseTestCase
     
         public function testOrder()
         {
    -        $select = $this->emptySelect()->order('bla');
    -        // tested by testGetSortColumns
    +        $this->markTestIncomplete('testOrder is not implemented yet, order support for ldap queries is incomplete');
         }
     
    -    public function testListFields()
    -    {
    -        $select = $this->prepareSelect();
    -        $this->assertEquals(
    -            array('testIntColumn', 'testStringColumn'),
    -            $select->listFields()
    -        );
    -    }
    -
    -    public function testGetSortColumns()
    -    {
    -        $select = $this->prepareSelect();
    -        $cols = $select->getSortColumns();
    -        $this->assertEquals('testIntColumn', $cols[0][0]);
    -    }
    -
    -    public function testCreateQuery()
    +    public function testRenderFilter()
         {
             $select = $this->prepareSelect();
             $res = '(&(objectClass=dummyClass)(testIntColumn=1)(testStringColumn=test)(testWildcard=abc*))';
    -        $this->assertEquals($res, $select->create());
    +        $this->assertEquals($res, (string) $select);
         }
     }
    diff --git a/test/php/library/Icinga/UserTest.php b/test/php/library/Icinga/UserTest.php
    index dc55dc62d..97cf412c0 100644
    --- a/test/php/library/Icinga/UserTest.php
    +++ b/test/php/library/Icinga/UserTest.php
    @@ -67,13 +67,15 @@ class UserTest extends BaseTestCase
                 'test',
                 'test/some/specific',
                 'test/more/*',
    -            'test/wildcard-with-wildcard/*'
    +            'test/wildcard-with-wildcard/*',
    +            'test/even-more/specific-with-wildcard/*'
             ));
             $this->assertTrue($user->can('test'));
             $this->assertTrue($user->can('test/some/specific'));
             $this->assertTrue($user->can('test/more/everything'));
             $this->assertTrue($user->can('test/wildcard-with-wildcard/*'));
             $this->assertTrue($user->can('test/wildcard-with-wildcard/sub/sub'));
    +        $this->assertTrue($user->can('test/even-more/*'));
             $this->assertFalse($user->can('not/test'));
             $this->assertFalse($user->can('test/some/not/so/specific'));
             $this->assertFalse($user->can('test/wildcard2/*'));