diff options
Diffstat (limited to '')
42 files changed, 8204 insertions, 0 deletions
diff --git a/application/forms/Account/ChangePasswordForm.php b/application/forms/Account/ChangePasswordForm.php new file mode 100644 index 0000000..5bca11c --- /dev/null +++ b/application/forms/Account/ChangePasswordForm.php @@ -0,0 +1,123 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Account; + +use Icinga\Authentication\User\DbUserBackend; +use Icinga\Data\Filter\Filter; +use Icinga\User; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form for changing user passwords + */ +class ChangePasswordForm extends Form +{ + /** + * The user backend + * + * @var DbUserBackend + */ + protected $backend; + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setSubmitLabel($this->translate('Update Account')); + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $this->addElement( + 'password', + 'old_password', + array( + 'label' => $this->translate('Old Password'), + 'required' => true + ) + ); + $this->addElement( + 'password', + 'new_password', + array( + 'label' => $this->translate('New Password'), + 'required' => true + ) + ); + $this->addElement( + 'password', + 'new_password_confirmation', + array( + 'label' => $this->translate('Confirm New Password'), + 'required' => true, + 'validators' => array( + array('identical', false, array('new_password')) + ) + ) + ); + } + + /** + * {@inheritdoc} + */ + public function onSuccess() + { + $backend = $this->getBackend(); + $backend->update( + $backend->getBaseTable(), + array('password' => $this->getElement('new_password')->getValue()), + Filter::where('user_name', $this->Auth()->getUser()->getUsername()) + ); + Notification::success($this->translate('Account updated')); + } + + /** + * {@inheritdoc} + */ + public function isValid($formData) + { + $valid = parent::isValid($formData); + if (! $valid) { + return false; + } + + $oldPasswordEl = $this->getElement('old_password'); + + if (! $this->backend->authenticate($this->Auth()->getUser(), $oldPasswordEl->getValue())) { + $oldPasswordEl->addError($this->translate('Old password is invalid')); + $this->markAsError(); + return false; + } + + return true; + } + + /** + * Get the user backend + * + * @return DbUserBackend + */ + public function getBackend() + { + return $this->backend; + } + + /** + * Set the user backend + * + * @param DbUserBackend $backend + * + * @return $this + */ + public function setBackend(DbUserBackend $backend) + { + $this->backend = $backend; + return $this; + } +} diff --git a/application/forms/AcknowledgeApplicationStateMessageForm.php b/application/forms/AcknowledgeApplicationStateMessageForm.php new file mode 100644 index 0000000..61f5824 --- /dev/null +++ b/application/forms/AcknowledgeApplicationStateMessageForm.php @@ -0,0 +1,75 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Application\Hook\ApplicationStateHook; +use Icinga\Web\ApplicationStateCookie; +use Icinga\Web\Form; +use Icinga\Web\Url; + +class AcknowledgeApplicationStateMessageForm extends Form +{ + public function init() + { + $this->setAction(Url::fromPath('application-state/acknowledge-message')); + $this->setAttrib('class', 'application-state-acknowledge-message-control'); + $this->setRedirectUrl('application-state/summary'); + } + + public function addSubmitButton() + { + $this->addElement( + 'button', + 'btn_submit', + [ + 'class' => 'link-button spinner', + 'decorators' => [ + 'ViewHelper', + ['HtmlTag', ['tag' => 'div', 'class' => 'control-group form-controls']] + ], + 'escape' => false, + 'ignore' => true, + 'label' => $this->getView()->icon('cancel'), + 'title' => $this->translate('Acknowledge message'), + 'type' => 'submit' + ] + ); + return $this; + } + + public function createElements(array $formData = []) + { + $this->addElements( + [ + [ + 'hidden', + 'id', + [ + 'required' => true, + 'validators' => ['NotEmpty'], + 'decorators' => ['ViewHelper'] + ] + ] + ] + ); + + return $this; + } + + public function onSuccess() + { + $cookie = new ApplicationStateCookie(); + + $ack = $cookie->getAcknowledgedMessages(); + $ack[] = $this->getValue('id'); + + $active = ApplicationStateHook::getAllMessages(); + + $cookie->setAcknowledgedMessages(array_keys(array_intersect_key($active, array_flip($ack)))); + + $this->getResponse()->setCookie($cookie); + + return true; + } +} diff --git a/application/forms/ActionForm.php b/application/forms/ActionForm.php new file mode 100644 index 0000000..5b5b6ed --- /dev/null +++ b/application/forms/ActionForm.php @@ -0,0 +1,78 @@ +<?php +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Web\Form; + +class ActionForm extends Form +{ + /** + * The icon shown on the button + * + * @var string + */ + protected $icon = 'arrows-cw'; + + /** + * Set the icon to show on the button + * + * @param string $name + * + * @return $this + */ + public function setIcon($name) + { + $this->icon = (string) $name; + return $this; + } + + public function init() + { + $this->setAttrib('class', 'inline'); + $this->setUidDisabled(true); + $this->setDecorators(['FormElements', 'Form']); + } + + public function createElements(array $formData) + { + $this->addElement( + 'hidden', + 'identifier', + [ + 'required' => true, + 'decorators' => ['ViewHelper'] + ] + ); + $this->addElement( + 'button', + 'btn_submit', + [ + 'escape' => false, + 'type' => 'submit', + 'class' => 'link-button spinner', + 'value' => 'btn_submit', + 'decorators' => ['ViewHelper'], + 'label' => $this->getView()->icon($this->icon), + 'title' => $this->getDescription() + ] + ); + } + + public function isValid($formData) + { + $valid = parent::isValid($formData); + + if ($valid) { + $valid = ConfigFormEventsHook::runIsValid($this); + } + + return $valid; + } + + public function onSuccess() + { + ConfigFormEventsHook::runOnSuccess($this); + } +} diff --git a/application/forms/Announcement/AcknowledgeAnnouncementForm.php b/application/forms/Announcement/AcknowledgeAnnouncementForm.php new file mode 100644 index 0000000..85fecdc --- /dev/null +++ b/application/forms/Announcement/AcknowledgeAnnouncementForm.php @@ -0,0 +1,92 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Announcement; + +use Icinga\Data\Filter\Filter; +use Icinga\Web\Announcement\AnnouncementCookie; +use Icinga\Web\Announcement\AnnouncementIniRepository; +use Icinga\Web\Form; +use Icinga\Web\Url; + +class AcknowledgeAnnouncementForm extends Form +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setAction(Url::fromPath('announcements/acknowledge')); + $this->setAttrib('class', 'acknowledge-announcement-control'); + $this->setRedirectUrl('layout/announcements'); + } + + /** + * {@inheritdoc} + */ + public function addSubmitButton() + { + $this->addElement( + 'button', + 'btn_submit', + array( + 'class' => 'link-button spinner', + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ), + 'escape' => false, + 'ignore' => true, + 'label' => $this->getView()->icon('cancel'), + 'title' => $this->translate('Acknowledge this announcement'), + 'type' => 'submit' + ) + ); + return $this; + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData = array()) + { + $this->addElements( + array( + array( + 'hidden', + 'hash', + array( + 'required' => true, + 'validators' => array('NotEmpty'), + 'decorators' => array('ViewHelper') + ) + ) + ) + ); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function onSuccess() + { + $cookie = new AnnouncementCookie(); + $repo = new AnnouncementIniRepository(); + $query = $repo->findActive(); + $filter = array(); + foreach ($cookie->getAcknowledged() as $hash) { + $filter[] = Filter::expression('hash', '=', $hash); + } + $query->addFilter(Filter::matchAny($filter)); + $acknowledged = array(); + foreach ($query as $row) { + $acknowledged[] = $row->hash; + } + $acknowledged[] = $this->getElement('hash')->getValue(); + $cookie->setAcknowledged($acknowledged); + $this->getResponse()->setCookie($cookie); + return true; + } +} diff --git a/application/forms/Announcement/AnnouncementForm.php b/application/forms/Announcement/AnnouncementForm.php new file mode 100644 index 0000000..4da47e2 --- /dev/null +++ b/application/forms/Announcement/AnnouncementForm.php @@ -0,0 +1,135 @@ +<?php +/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Announcement; + +use DateTime; +use Icinga\Authentication\Auth; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; + +/** + * Create, update and delete announcements + */ +class AnnouncementForm extends RepositoryForm +{ + protected function fetchEntry() + { + $entry = parent::fetchEntry(); + if ($entry !== false) { + if ($entry->start !== null) { + $entry->start = (new DateTime())->setTimestamp($entry->start); + } + if ($entry->end !== null) { + $entry->end = (new DateTime())->setTimestamp($entry->end); + } + } + + return $entry; + } + + /** + * {@inheritDoc} + */ + protected function createInsertElements(array $formData) + { + $this->addElement( + 'text', + 'author', + array( + 'disabled' => ! $this->getRequest()->isApiRequest(), + 'required' => true, + 'value' => Auth::getInstance()->getUser()->getUsername() + ) + ); + $this->addElement( + 'textarea', + 'message', + array( + 'description' => $this->translate('The message to display to users'), + 'label' => $this->translate('Message'), + 'required' => true + ) + ); + $this->addElement( + 'dateTimePicker', + 'start', + array( + 'description' => $this->translate('The time to display the announcement from'), + 'label' => $this->translate('Start'), + 'placeholder' => new DateTime('tomorrow'), + 'required' => true + ) + ); + $this->addElement( + 'dateTimePicker', + 'end', + array( + 'description' => $this->translate('The time to display the announcement until'), + 'label' => $this->translate('End'), + 'placeholder' => new DateTime('tomorrow +1day'), + 'required' => true + ) + ); + + $this->setTitle($this->translate('Create a new announcement')); + $this->setSubmitLabel($this->translate('Create')); + } + /** + * {@inheritDoc} + */ + protected function createUpdateElements(array $formData) + { + $this->createInsertElements($formData); + $this->setTitle(sprintf($this->translate('Edit announcement %s'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Save')); + } + + /** + * {@inheritDoc} + */ + protected function createDeleteElements(array $formData) + { + $this->setTitle(sprintf($this->translate('Remove announcement %s?'), $this->getIdentifier())); + $this->setSubmitLabel($this->translate('Yes')); + $this->setAttrib('class', 'icinga-controls'); + } + + /** + * {@inheritDoc} + */ + protected function createFilter() + { + return Filter::where('id', $this->getIdentifier()); + } + + /** + * {@inheritDoc} + */ + protected function getInsertMessage($success) + { + return $success + ? $this->translate('Announcement created') + : $this->translate('Failed to create announcement'); + } + + /** + * {@inheritDoc} + */ + protected function getUpdateMessage($success) + { + return $success + ? $this->translate('Announcement updated') + : $this->translate('Failed to update announcement'); + } + + /** + * {@inheritDoc} + */ + protected function getDeleteMessage($success) + { + return $success + ? $this->translate('Announcement removed') + : $this->translate('Failed to remove announcement'); + } +} diff --git a/application/forms/Authentication/LoginForm.php b/application/forms/Authentication/LoginForm.php new file mode 100644 index 0000000..87b32ab --- /dev/null +++ b/application/forms/Authentication/LoginForm.php @@ -0,0 +1,214 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Authentication; + +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Hook\AuthenticationHook; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Authentication\User\ExternalBackend; +use Icinga\Common\Database; +use Icinga\Exception\Http\HttpBadRequestException; +use Icinga\User; +use Icinga\Web\Form; +use Icinga\Web\RememberMe; +use Icinga\Web\Url; + +/** + * Form for user authentication + */ +class LoginForm extends Form +{ + use Database; + + const DEFAULT_CLASSES = 'icinga-controls'; + + /** + * Redirect URL + */ + const REDIRECT_URL = 'dashboard'; + + public static $defaultElementDecorators = [ + ['ViewHelper', ['separator' => '']], + ['Help', []], + ['Errors', ['separator' => '']], + ['HtmlTag', ['tag' => 'div', 'class' => 'control-group']] + ]; + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setRequiredCue(null); + $this->setName('form_login'); + $this->setSubmitLabel($this->translate('Login')); + $this->setProgressLabel($this->translate('Logging in')); + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'username', + array( + 'autocapitalize' => 'off', + 'autocomplete' => 'username', + 'class' => false === isset($formData['username']) ? 'autofocus' : '', + 'placeholder' => $this->translate('Username'), + 'required' => true + ) + ); + $this->addElement( + 'password', + 'password', + array( + 'required' => true, + 'autocomplete' => 'current-password', + 'placeholder' => $this->translate('Password'), + 'class' => isset($formData['username']) ? 'autofocus' : '' + ) + ); + $this->addElement( + 'checkbox', + 'rememberme', + [ + 'label' => $this->translate('Stay logged in'), + 'decorators' => [ + ['ViewHelper', ['separator' => '']], + ['Label', [ + 'tag' => 'span', + 'separator' => '', + 'class' => 'control-label', + 'placement' => 'APPEND' + ]], + ['Help', []], + ['Errors', ['separator' => '']], + ['HtmlTag', ['tag' => 'div', 'class' => 'control-group remember-me-box']] + ] + ] + ); + if (! RememberMe::isSupported()) { + $this->getElement('rememberme') + ->setAttrib('disabled', true) + ->setDescription($this->translate( + 'Staying logged in requires a database configuration backend' + . ' and an appropriate OpenSSL encryption method' + )); + } + + $this->addElement( + 'hidden', + 'redirect', + array( + 'value' => Url::fromRequest()->getParam('redirect') + ) + ); + } + + /** + * {@inheritdoc} + */ + public function getRedirectUrl() + { + $redirect = null; + if ($this->created) { + $redirect = $this->getElement('redirect')->getValue(); + } + + if (empty($redirect) || strpos($redirect, 'authentication/logout') !== false) { + $redirect = static::REDIRECT_URL; + } + + $redirectUrl = Url::fromPath($redirect); + if ($redirectUrl->isExternal()) { + throw new HttpBadRequestException('nope'); + } + + return $redirectUrl; + } + + /** + * {@inheritdoc} + */ + public function onSuccess() + { + $auth = Auth::getInstance(); + $authChain = $auth->getAuthChain(); + $authChain->setSkipExternalBackends(true); + $user = new User($this->getElement('username')->getValue()); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + $password = $this->getElement('password')->getValue(); + $authenticated = $authChain->authenticate($user, $password); + if ($authenticated) { + $auth->setAuthenticated($user); + if ($this->getElement('rememberme')->isChecked()) { + try { + $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); + $this->getResponse()->setCookie($rememberMe->getCookie()); + $rememberMe->persist(); + } catch (Exception $e) { + Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); + } + } + + // Call provided AuthenticationHook(s) after successful login + AuthenticationHook::triggerLogin($user); + $this->getResponse()->setRerenderLayout(true); + return true; + } + switch ($authChain->getError()) { + case $authChain::EEMPTY: + $this->addError($this->translate( + 'No authentication methods available.' + . ' Did you create authentication.ini when setting up Icinga Web 2?' + )); + break; + case $authChain::EFAIL: + $this->addError($this->translate( + 'All configured authentication methods failed.' + . ' Please check the system log or Icinga Web 2 log for more information.' + )); + break; + /** @noinspection PhpMissingBreakStatementInspection */ + case $authChain::ENOTALL: + $this->addError($this->translate( + 'Please note that not all authentication methods were available.' + . ' Check the system log or Icinga Web 2 log for more information.' + )); + // Move to default + default: + $this->getElement('password')->addError($this->translate('Incorrect username or password')); + break; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onRequest() + { + $auth = Auth::getInstance(); + $onlyExternal = true; + // TODO(el): This may be set on the auth chain once iterated. See Auth::authExternal(). + foreach ($auth->getAuthChain() as $backend) { + if (! $backend instanceof ExternalBackend) { + $onlyExternal = false; + } + } + if ($onlyExternal) { + $this->addError($this->translate( + 'You\'re currently not authenticated using any of the web server\'s authentication mechanisms.' + . ' Make sure you\'ll configure such, otherwise you\'ll not be able to login.' + )); + } + } +} diff --git a/application/forms/AutoRefreshForm.php b/application/forms/AutoRefreshForm.php new file mode 100644 index 0000000..122f635 --- /dev/null +++ b/application/forms/AutoRefreshForm.php @@ -0,0 +1,83 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Application\Logger; +use Icinga\User\Preferences; +use Icinga\Web\Form; +use Icinga\Web\Notification; +use Icinga\Web\Session; +use Icinga\Web\Url; + +/** + * Form class to adjust user auto refresh preferences + */ +class AutoRefreshForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_auto_refresh'); + // Post against the current location + $this->setAction(''); + } + + /** + * Adjust preferences and persist them + * + * @see Form::onSuccess() + */ + public function onSuccess() + { + /** @var Preferences $preferences */ + $preferences = $this->getRequest()->getUser()->getPreferences(); + $icingaweb = $preferences->get('icingaweb'); + + if ((bool) $preferences->getValue('icingaweb', 'auto_refresh', true) === false) { + $icingaweb['auto_refresh'] = '1'; + $notification = $this->translate('Auto refresh successfully enabled'); + } else { + $icingaweb['auto_refresh'] = '0'; + $notification = $this->translate('Auto refresh successfully disabled'); + } + $preferences->icingaweb = $icingaweb; + + Session::getSession()->user->setPreferences($preferences); + Notification::success($notification); + + $this->getResponse()->setHeader('X-Icinga-Rerender-Layout', 'yes'); + $this->setRedirectUrl(Url::fromRequest()->without('renderLayout')); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $preferences = $this->getRequest()->getUser()->getPreferences(); + + if ((bool) $preferences->getValue('icingaweb', 'auto_refresh', true) === false) { + $value = $this->translate('Enable auto refresh'); + } else { + $value = $this->translate('Disable auto refresh'); + } + + $this->addElements(array( + array( + 'button', + 'btn_submit', + array( + 'ignore' => true, + 'type' => 'submit', + 'value' => $value, + 'decorators' => array('ViewHelper'), + 'escape' => false, + 'class' => 'link-like' + ) + ) + )); + } +} diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php new file mode 100644 index 0000000..21f76a1 --- /dev/null +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -0,0 +1,105 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\General; + +use Icinga\Application\Icinga; +use Icinga\Data\ResourceFactory; +use Icinga\Web\Form; + +/** + * Configuration form for general application options + * + * This form is not used directly but as subform to the {@link GeneralConfigForm}. + */ +class ApplicationConfigForm extends Form +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_config_general_application'); + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function createElements(array $formData) + { + $this->addElement( + 'checkbox', + 'global_show_stacktraces', + array( + 'value' => true, + 'label' => $this->translate('Show Stacktraces'), + 'description' => $this->translate( + 'Set whether to show an exception\'s stacktrace by default. This can also' + . ' be set in a user\'s preferences with the appropriate permission.' + ) + ) + ); + + $this->addElement( + 'checkbox', + 'global_show_application_state_messages', + array( + 'value' => true, + 'label' => $this->translate('Show Application State Messages'), + 'description' => $this->translate( + "Set whether to show application state messages." + . " This can also be set in a user's preferences." + ) + ) + ); + + $this->addElement( + 'checkbox', + 'security_use_strict_csp', + [ + 'label' => $this->translate('Enable strict content security policy'), + 'description' => $this->translate( + 'Set whether to to use strict content security policy (CSP).' + . ' This setting helps to protect from cross-site scripting (XSS).' + ) + ] + ); + + $this->addElement( + 'text', + 'global_module_path', + array( + 'label' => $this->translate('Module Path'), + 'required' => true, + 'value' => implode(':', Icinga::app()->getModuleManager()->getModuleDirs()), + 'description' => $this->translate( + 'Contains the directories that will be searched for available modules, separated by ' + . 'colons. Modules that don\'t exist in these directories can still be symlinked in ' + . 'the module folder, but won\'t show up in the list of disabled modules.' + ) + ) + ); + + $backends = array_keys(ResourceFactory::getResourceConfigs()->toArray()); + $backends = array_combine($backends, $backends); + + $this->addElement( + 'select', + 'global_config_resource', + array( + 'required' => true, + 'multiOptions' => array_merge( + ['' => sprintf(' - %s - ', $this->translate('Please choose'))], + $backends + ), + 'disable' => [''], + 'value' => '', + 'label' => $this->translate('Configuration Database') + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php b/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php new file mode 100644 index 0000000..0ff6c32 --- /dev/null +++ b/application/forms/Config/General/DefaultAuthenticationDomainConfigForm.php @@ -0,0 +1,46 @@ +<?php +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\General; + +use Icinga\Web\Form; + +/** + * Configuration form for the default domain for authentication + * + * This form is not used directly but as subform to the {@link GeneralConfigForm}. + */ +class DefaultAuthenticationDomainConfigForm extends Form +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_config_general_authentication'); + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'authentication_default_domain', + array( + 'label' => $this->translate('Default Login Domain'), + 'description' => $this->translate( + 'If a user logs in without specifying any domain (e.g. "jdoe" instead of "jdoe@example.com"),' + . ' this default domain will be assumed for the user. Note that if none your LDAP authentication' + . ' backends are configured to be responsible for this domain or if none of your authentication' + . ' backends holds usernames with the domain part, users will not be able to login.' + ) + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/General/LoggingConfigForm.php b/application/forms/Config/General/LoggingConfigForm.php new file mode 100644 index 0000000..bbc7723 --- /dev/null +++ b/application/forms/Config/General/LoggingConfigForm.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\General; + +use Icinga\Application\Logger; +use Icinga\Application\Logger\Writer\SyslogWriter; +use Icinga\Application\Platform; +use Icinga\Web\Form; + +/** + * Configuration form for logging options + * + * This form is not used directly but as subform for the {@link GeneralConfigForm}. + */ +class LoggingConfigForm extends Form +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_config_general_logging'); + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function createElements(array $formData) + { + $defaultType = getenv('ICINGAWEB_OFFICIAL_DOCKER_IMAGE') ? 'php' : 'syslog'; + + $this->addElement( + 'select', + 'logging_log', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Logging Type'), + 'description' => $this->translate('The type of logging to utilize.'), + 'value' => $defaultType, + 'multiOptions' => array( + 'syslog' => 'Syslog', + 'php' => $this->translate('Webserver Log', 'app.config.logging.type'), + 'file' => $this->translate('File', 'app.config.logging.type'), + 'none' => $this->translate('None', 'app.config.logging.type') + ) + ) + ); + + if (! isset($formData['logging_log']) || $formData['logging_log'] !== 'none') { + $this->addElement( + 'select', + 'logging_level', + array( + 'required' => true, + 'label' => $this->translate('Logging Level'), + 'description' => $this->translate('The maximum logging level to emit.'), + 'multiOptions' => array( + Logger::$levels[Logger::ERROR] => $this->translate('Error', 'app.config.logging.level'), + Logger::$levels[Logger::WARNING] => $this->translate('Warning', 'app.config.logging.level'), + Logger::$levels[Logger::INFO] => $this->translate('Information', 'app.config.logging.level'), + Logger::$levels[Logger::DEBUG] => $this->translate('Debug', 'app.config.logging.level') + ) + ) + ); + } + + if (! isset($formData['logging_log']) || in_array($formData['logging_log'], array('syslog', 'php'))) { + $this->addElement( + 'text', + 'logging_application', + array( + 'required' => true, + 'label' => $this->translate('Application Prefix'), + 'description' => $this->translate( + 'The name of the application by which to prefix log messages.' + ), + 'requirement' => $this->translate('The application prefix must not contain whitespace.'), + 'value' => 'icingaweb2', + 'validators' => array( + array( + 'Regex', + false, + array( + 'pattern' => '/^\S+$/', + 'messages' => array( + 'regexNotMatch' => $this->translate( + 'The application prefix must not contain whitespace.' + ) + ) + ) + ) + ) + ) + ); + + if ((isset($formData['logging_log']) ? $formData['logging_log'] : $defaultType) === 'syslog') { + if (Platform::isWindows()) { + /* @see https://secure.php.net/manual/en/function.openlog.php */ + $this->addElement( + 'hidden', + 'logging_facility', + array( + 'value' => 'user', + 'disabled' => true + ) + ); + } else { + $facilities = array_keys(SyslogWriter::$facilities); + $this->addElement( + 'select', + 'logging_facility', + array( + 'required' => true, + 'label' => $this->translate('Facility'), + 'description' => $this->translate('The syslog facility to utilize.'), + 'value' => 'user', + 'multiOptions' => array_combine($facilities, $facilities) + ) + ); + } + } + } elseif (isset($formData['logging_log']) && $formData['logging_log'] === 'file') { + $this->addElement( + 'text', + 'logging_file', + array( + 'required' => true, + 'label' => $this->translate('File path'), + 'description' => $this->translate('The full path to the log file to write messages to.'), + 'value' => '/var/log/icingaweb2/icingaweb2.log', + 'validators' => array('WritablePathValidator') + ) + ); + } + + return $this; + } +} diff --git a/application/forms/Config/General/ThemingConfigForm.php b/application/forms/Config/General/ThemingConfigForm.php new file mode 100644 index 0000000..54ef2b1 --- /dev/null +++ b/application/forms/Config/General/ThemingConfigForm.php @@ -0,0 +1,78 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\General; + +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Web\Form; +use Icinga\Web\StyleSheet; + +/** + * Configuration form for theming options + * + * This form is not used directly but as subform for the {@link GeneralConfigForm}. + */ +class ThemingConfigForm extends Form +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_config_general_theming'); + } + + /** + * {@inheritdoc} + * + * @return $this + */ + public function createElements(array $formData) + { + $themes = Icinga::app()->getThemes(); + $themes[StyleSheet::DEFAULT_THEME] .= ' (' . $this->translate('default') . ')'; + + $this->addElement( + 'select', + 'themes_default', + array( + 'description' => $this->translate('The default theme', 'Form element description'), + 'disabled' => count($themes) < 2 ? 'disabled' : null, + 'label' => $this->translate('Default Theme', 'Form element label'), + 'multiOptions' => $themes, + 'value' => StyleSheet::DEFAULT_THEME + ) + ); + + $this->addElement( + 'checkbox', + 'themes_disabled', + array( + 'description' => $this->translate( + 'Check this box for disallowing users to change the theme. If a default theme is set, it will be' + . ' used nonetheless', + 'Form element description' + ), + 'label' => $this->translate('Users Can\'t Change Theme', 'Form element label') + ) + ); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + if ($values['themes_default'] === '' || $values['themes_default'] === StyleSheet::DEFAULT_THEME) { + $values['themes_default'] = null; + } + if (! $values['themes_disabled']) { + $values['themes_disabled'] = null; + } + return $values; + } +} diff --git a/application/forms/Config/GeneralConfigForm.php b/application/forms/Config/GeneralConfigForm.php new file mode 100644 index 0000000..5f15512 --- /dev/null +++ b/application/forms/Config/GeneralConfigForm.php @@ -0,0 +1,40 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config; + +use Icinga\Forms\Config\General\ApplicationConfigForm; +use Icinga\Forms\Config\General\DefaultAuthenticationDomainConfigForm; +use Icinga\Forms\Config\General\LoggingConfigForm; +use Icinga\Forms\Config\General\ThemingConfigForm; +use Icinga\Forms\ConfigForm; + +/** + * Configuration form for application-wide options + */ +class GeneralConfigForm extends ConfigForm +{ + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_config_general'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $appConfigForm = new ApplicationConfigForm(); + $loggingConfigForm = new LoggingConfigForm(); + $themingConfigForm = new ThemingConfigForm(); + $domainConfigForm = new DefaultAuthenticationDomainConfigForm(); + $this->addSubForm($appConfigForm->create($formData)); + $this->addSubForm($loggingConfigForm->create($formData)); + $this->addSubForm($themingConfigForm->create($formData)); + $this->addSubForm($domainConfigForm->create($formData)); + } +} diff --git a/application/forms/Config/Resource/DbResourceForm.php b/application/forms/Config/Resource/DbResourceForm.php new file mode 100644 index 0000000..c9d7601 --- /dev/null +++ b/application/forms/Config/Resource/DbResourceForm.php @@ -0,0 +1,239 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\Resource; + +use Icinga\Application\Platform; +use Icinga\Web\Form; + +/** + * Form class for adding/modifying database resources + */ +class DbResourceForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_resource_db'); + } + + /** + * Create and add elements to this form + * + * @param array $formData The data sent by the user + */ + public function createElements(array $formData) + { + $dbChoices = array(); + if (Platform::hasMysqlSupport()) { + $dbChoices['mysql'] = 'MySQL'; + } + if (Platform::hasPostgresqlSupport()) { + $dbChoices['pgsql'] = 'PostgreSQL'; + } + if (Platform::hasMssqlSupport()) { + $dbChoices['mssql'] = 'MSSQL'; + } + if (Platform::hasIbmSupport()) { + $dbChoices['ibm'] = 'IBM (DB2)'; + } + if (Platform::hasOracleSupport()) { + $dbChoices['oracle'] = 'Oracle'; + } + if (Platform::hasOciSupport()) { + $dbChoices['oci'] = 'Oracle (OCI8)'; + } + if (Platform::hasSqliteSupport()) { + $dbChoices['sqlite'] = 'SQLite'; + } + + $offerPostgres = false; + $offerMysql = false; + $dbChoice = isset($formData['db']) ? $formData['db'] : key($dbChoices); + if ($dbChoice === 'pgsql') { + $offerPostgres = true; + } elseif ($dbChoice === 'mysql') { + $offerMysql = true; + } + + if ($dbChoice === 'oracle' || $dbChoice === 'oci') { + $hostIsRequired = false; + } else { + $hostIsRequired = true; + } + + $socketInfo = ''; + if ($offerPostgres) { + $socketInfo = $this->translate( + 'For using unix domain sockets, specify the path to the unix domain socket directory' + ); + } elseif ($offerMysql) { + $socketInfo = $this->translate( + 'For using unix domain sockets, specify localhost' + ); + } + + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Resource Name'), + 'description' => $this->translate('The unique name of this resource') + ) + ); + $this->addElement( + 'select', + 'db', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Database Type'), + 'description' => $this->translate('The type of SQL database'), + 'multiOptions' => $dbChoices + ) + ); + if ($dbChoice === 'sqlite') { + $this->addElement( + 'text', + 'dbname', + array( + 'required' => true, + 'label' => $this->translate('Database Name'), + 'description' => $this->translate('The name of the database to use') + ) + ); + } else { + $this->addElement( + 'text', + 'host', + array ( + 'required' => $hostIsRequired, + 'label' => $this->translate('Host'), + 'description' => $this->translate('The hostname of the database') + . ($socketInfo ? '. ' . $socketInfo : ''), + 'value' => $hostIsRequired ? 'localhost' : '' + ) + ); + $this->addElement( + 'number', + 'port', + array( + 'description' => $this->translate('The port to use'), + 'label' => $this->translate('Port'), + 'preserveDefault' => true, + 'required' => $offerPostgres, + 'value' => $offerPostgres ? 5432 : null + ) + ); + $this->addElement( + 'text', + 'dbname', + array( + 'required' => true, + 'label' => $this->translate('Database Name'), + 'description' => $this->translate('The name of the database to use') + ) + ); + $this->addElement( + 'text', + 'username', + array ( + 'required' => true, + 'label' => $this->translate('Username'), + 'description' => $this->translate('The user name to use for authentication') + ) + ); + $this->addElement( + 'password', + 'password', + array( + 'required' => true, + 'renderPassword' => true, + 'label' => $this->translate('Password'), + 'description' => $this->translate('The password to use for authentication'), + 'autocomplete' => 'new-password' + ) + ); + $this->addElement( + 'text', + 'charset', + array ( + 'description' => $this->translate('The character set for the database'), + 'label' => $this->translate('Character Set') + ) + ); + $this->addElement( + 'checkbox', + 'use_ssl', + array( + 'autosubmit' => true, + 'label' => $this->translate('Use SSL'), + 'description' => $this->translate( + 'Whether to encrypt the connection or to authenticate using certificates' + ) + ) + ); + if (isset($formData['use_ssl']) && $formData['use_ssl']) { + if (defined('\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) { + $this->addElement( + 'checkbox', + 'ssl_do_not_verify_server_cert', + array( + 'label' => $this->translate('SSL Do Not Verify Server Certificate'), + 'description' => $this->translate( + 'Whether to disable verification of the server certificate' + ) + ) + ); + } + $this->addElement( + 'text', + 'ssl_key', + array( + 'label' => $this->translate('SSL Key'), + 'description' => $this->translate('The client key file path') + ) + ); + $this->addElement( + 'text', + 'ssl_cert', + array( + 'label' => $this->translate('SSL Certificate'), + 'description' => $this->translate('The certificate file path') + ) + ); + $this->addElement( + 'text', + 'ssl_ca', + array( + 'label' => $this->translate('SSL CA'), + 'description' => $this->translate('The CA certificate file path') + ) + ); + $this->addElement( + 'text', + 'ssl_capath', + array( + 'label' => $this->translate('SSL CA Path'), + 'description' => $this->translate( + 'The trusted CA certificates in PEM format directory path' + ) + ) + ); + $this->addElement( + 'text', + 'ssl_cipher', + array( + 'label' => $this->translate('SSL Cipher'), + 'description' => $this->translate('The list of permissible ciphers') + ) + ); + } + } + + return $this; + } +} diff --git a/application/forms/Config/Resource/FileResourceForm.php b/application/forms/Config/Resource/FileResourceForm.php new file mode 100644 index 0000000..b98f1b4 --- /dev/null +++ b/application/forms/Config/Resource/FileResourceForm.php @@ -0,0 +1,67 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\Resource; + +use Zend_Validate_Callback; +use Icinga\Web\Form; + +/** + * Form class for adding/modifying file resources + */ +class FileResourceForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_resource_file'); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Resource Name'), + 'description' => $this->translate('The unique name of this resource') + ) + ); + $this->addElement( + 'text', + 'filename', + array( + 'required' => true, + 'label' => $this->translate('Filepath'), + 'description' => $this->translate('The filename to fetch information from'), + 'validators' => array('ReadablePathValidator') + ) + ); + $callbackValidator = new Zend_Validate_Callback(function ($value) { + return @preg_match($value, '') !== false; + }); + $callbackValidator->setMessage( + $this->translate('"%value%" is not a valid regular expression.'), + Zend_Validate_Callback::INVALID_VALUE + ); + $this->addElement( + 'text', + 'fields', + array( + 'required' => true, + 'label' => $this->translate('Pattern'), + 'description' => $this->translate('The pattern by which to identify columns.'), + 'requirement' => $this->translate('The column pattern must be a valid regular expression.'), + 'validators' => array($callbackValidator) + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/Resource/LdapResourceForm.php b/application/forms/Config/Resource/LdapResourceForm.php new file mode 100644 index 0000000..7ffccdc --- /dev/null +++ b/application/forms/Config/Resource/LdapResourceForm.php @@ -0,0 +1,129 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\Resource; + +use Icinga\Web\Form; +use Icinga\Web\Url; +use Icinga\Protocol\Ldap\LdapConnection; + +/** + * Form class for adding/modifying ldap resources + */ +class LdapResourceForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_resource_ldap'); + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $defaultPort = ! array_key_exists('encryption', $formData) || $formData['encryption'] !== LdapConnection::LDAPS + ? 389 + : 636; + + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Resource Name'), + 'description' => $this->translate('The unique name of this resource') + ) + ); + $this->addElement( + 'text', + 'hostname', + array( + 'required' => true, + 'label' => $this->translate('Host'), + 'description' => $this->translate( + 'The hostname or address of the LDAP server to use for authentication.' + . ' You can also provide multiple hosts separated by a space' + ), + 'value' => 'localhost' + ) + ); + $this->addElement( + 'number', + 'port', + array( + 'required' => true, + 'preserveDefault' => true, + 'label' => $this->translate('Port'), + 'description' => $this->translate('The port of the LDAP server to use for authentication'), + 'value' => $defaultPort + ) + ); + $this->addElement( + 'select', + 'encryption', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Encryption'), + 'description' => $this->translate( + 'Whether to encrypt communication. Choose STARTTLS or LDAPS for encrypted communication or' + . ' none for unencrypted communication' + ), + 'multiOptions' => array( + 'none' => $this->translate('None', 'resource.ldap.encryption'), + LdapConnection::STARTTLS => 'STARTTLS', + LdapConnection::LDAPS => 'LDAPS' + ) + ) + ); + + $this->addElement( + 'text', + 'root_dn', + array( + 'required' => true, + 'label' => $this->translate('Root DN'), + 'description' => $this->translate( + 'Only the root and its child nodes will be accessible on this resource.' + ) + ) + ); + $this->addElement( + 'text', + 'bind_dn', + array( + 'label' => $this->translate('Bind DN'), + 'description' => $this->translate( + 'The user dn to use for querying the ldap server. Leave the dn and password empty for attempting' + . ' an anonymous bind' + ) + ) + ); + $this->addElement( + 'password', + 'bind_pw', + array( + 'renderPassword' => true, + 'label' => $this->translate('Bind Password'), + 'description' => $this->translate('The password to use for querying the ldap server') + ) + ); + + $this->addElement( + 'number', + 'timeout', + array( + 'preserveDefault' => true, + 'label' => $this->translate('Timeout'), + 'description' => $this->translate('Connection timeout for every LDAP connection'), + 'value' => 5 // see LdapConnection::__construct() + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/Resource/SshResourceForm.php b/application/forms/Config/Resource/SshResourceForm.php new file mode 100644 index 0000000..a15dc8c --- /dev/null +++ b/application/forms/Config/Resource/SshResourceForm.php @@ -0,0 +1,148 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\Resource; + +use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; +use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Web\Form; +use Icinga\Util\File; +use Zend_Validate_Callback; + +/** + * Form class for adding/modifying ssh identity resources + */ +class SshResourceForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_resource_ssh'); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Resource Name'), + 'description' => $this->translate('The unique name of this resource') + ) + ); + $this->addElement( + 'text', + 'user', + array( + 'required' => true, + 'label' => $this->translate('User'), + 'description' => $this->translate( + 'User to log in as on the remote Icinga instance. Please note that key-based SSH login must be' + . ' possible for this user' + ) + ) + ); + + if ($this->getRequest()->getActionName() != 'editresource') { + $callbackValidator = new Zend_Validate_Callback(function ($value) { + if (substr(ltrim($value), 0, 7) === 'file://' + || openssl_pkey_get_private($value) === false + ) { + return false; + } + + return true; + }); + $callbackValidator->setMessage( + $this->translate('The given SSH key is invalid'), + Zend_Validate_Callback::INVALID_VALUE + ); + + $this->addElement( + 'textarea', + 'private_key', + array( + 'required' => true, + 'label' => $this->translate('Private Key'), + 'description' => $this->translate('The private key which will be used for the SSH connections'), + 'class' => 'resource ssh-identity', + 'validators' => array($callbackValidator) + ) + ); + } else { + $resourceName = $formData['name']; + $this->addElement( + 'note', + 'private_key_note', + array( + 'escape' => false, + 'label' => $this->translate('Private Key'), + 'value' => sprintf( + '<a href="%1$s" data-base-target="_next" title="%2$s" aria-label="%2$s">%3$s</a>', + $this->getView()->url('config/removeresource', array('resource' => $resourceName)), + $this->getView()->escape(sprintf($this->translate( + 'Remove the %s resource' + ), $resourceName)), + $this->translate('To modify the private key you must recreate this resource.') + ) + ) + ); + } + + return $this; + } + + /** + * Remove the assigned key to the resource + * + * @param ConfigObject $config + * + * @return bool + */ + public static function beforeRemove(ConfigObject $config) + { + $file = $config->private_key; + + if (file_exists($file)) { + unlink($file); + return true; + } + return false; + } + + /** + * Creates the assigned key to the resource + * + * @param ResourceConfigForm $form + * + * @return bool + */ + public static function beforeAdd(ResourceConfigForm $form) + { + $configDir = Icinga::app()->getConfigDir(); + $user = $form->getElement('user')->getValue(); + + $filePath = join(DIRECTORY_SEPARATOR, [$configDir, 'ssh', sha1($user)]); + if (! file_exists($filePath)) { + $file = File::create($filePath, 0600); + } else { + $form->error( + sprintf($form->translate('The private key for the user "%s" already exists.'), $user) + ); + return false; + } + + $file->fwrite($form->getElement('private_key')->getValue()); + + $form->getElement('private_key')->setValue($filePath); + + return true; + } +} diff --git a/application/forms/Config/ResourceConfigForm.php b/application/forms/Config/ResourceConfigForm.php new file mode 100644 index 0000000..c2d0d18 --- /dev/null +++ b/application/forms/Config/ResourceConfigForm.php @@ -0,0 +1,442 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config; + +use Icinga\Application\Config; +use InvalidArgumentException; +use Icinga\Application\Platform; +use Icinga\Exception\ConfigurationError; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Data\ResourceFactory; +use Icinga\Forms\ConfigForm; +use Icinga\Forms\Config\Resource\DbResourceForm; +use Icinga\Forms\Config\Resource\FileResourceForm; +use Icinga\Forms\Config\Resource\LdapResourceForm; +use Icinga\Forms\Config\Resource\SshResourceForm; +use Icinga\Web\Form; +use Icinga\Web\Notification; +use Zend_Form_Element; + +class ResourceConfigForm extends ConfigForm +{ + /** + * Bogus password when inspecting password elements + * + * @var string + */ + protected static $dummyPassword = '_web_form_5847ed1b5b8ca'; + + /** + * If the global config must be updated because a resource has been changed, this is the updated global config + * + * @var Config|null + */ + protected $updatedAppConfig = null; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_resource'); + $this->setSubmitLabel($this->translate('Save Changes')); + $this->setValidatePartial(true); + } + + /** + * Return a form object for the given resource type + * + * @param string $type The resource type for which to return a form + * + * @return Form + */ + public function getResourceForm($type) + { + if ($type === 'db') { + return new DbResourceForm(); + } elseif ($type === 'ldap') { + return new LdapResourceForm(); + } elseif ($type === 'file') { + return new FileResourceForm(); + } elseif ($type === 'ssh') { + return new SshResourceForm(); + } else { + throw new InvalidArgumentException(sprintf($this->translate('Invalid resource type "%s" provided'), $type)); + } + } + + /** + * Add a particular resource + * + * The backend to add is identified by the array-key `name'. + * + * @param array $values The values to extend the configuration with + * + * @return $this + * + * @throws InvalidArgumentException In case the resource does already exist + */ + public function add(array $values) + { + $name = isset($values['name']) ? $values['name'] : ''; + if (! $name) { + throw new InvalidArgumentException($this->translate('Resource name missing')); + } elseif ($this->config->hasSection($name)) { + throw new InvalidArgumentException($this->translate('Resource already exists')); + } + + unset($values['name']); + $this->config->setSection($name, $values); + return $this; + } + + /** + * Edit a particular resource + * + * @param string $name The name of the resource to edit + * @param array $values The values to edit the configuration with + * + * @return ConfigObject The edited configuration + * + * @throws InvalidArgumentException In case the resource does not exist + */ + public function edit($name, array $values) + { + if (! $name) { + throw new InvalidArgumentException($this->translate('Old resource name missing')); + } elseif (! ($newName = isset($values['name']) ? $values['name'] : '')) { + throw new InvalidArgumentException($this->translate('New resource name missing')); + } elseif (! $this->config->hasSection($name)) { + throw new InvalidArgumentException($this->translate('Unknown resource provided')); + } + + $resourceConfig = $this->config->getSection($name); + $this->config->removeSection($name); + unset($values['name']); + $this->config->setSection($newName, $resourceConfig->merge($values)); + + if ($newName !== $name) { + $appConfig = Config::app(); + $section = $appConfig->getSection('global'); + if ($section->config_resource === $name) { + $section->config_resource = $newName; + $this->updatedAppConfig = $appConfig->setSection('global', $section); + } + } + + return $resourceConfig; + } + + /** + * Remove a particular resource + * + * @param string $name The name of the resource to remove + * + * @return ConfigObject The removed resource configuration + * + * @throws InvalidArgumentException In case the resource does not exist + */ + public function remove($name) + { + if (! $name) { + throw new InvalidArgumentException($this->translate('Resource name missing')); + } elseif (! $this->config->hasSection($name)) { + throw new InvalidArgumentException($this->translate('Unknown resource provided')); + } + + $resourceConfig = $this->config->getSection($name); + $resourceForm = $this->getResourceForm($resourceConfig->type); + if (method_exists($resourceForm, 'beforeRemove')) { + $resourceForm::beforeRemove($resourceConfig); + } + + $this->config->removeSection($name); + return $resourceConfig; + } + + /** + * Add or edit a resource 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. + * + * @see Form::onSuccess() + */ + public function onSuccess() + { + $resourceForm = $this->getResourceForm($this->getElement('type')->getValue()); + + if (($el = $this->getElement('force_creation')) === null || false === $el->isChecked()) { + $inspection = static::inspectResource($this); + if ($inspection !== null && $inspection->hasError()) { + $this->error($inspection->getError()); + $this->addElement($this->getForceCreationCheckbox()); + return false; + } + } + + $resource = $this->request->getQuery('resource'); + try { + if ($resource === null) { // create new resource + if (method_exists($resourceForm, 'beforeAdd')) { + if (! $resourceForm::beforeAdd($this)) { + return false; + } + } + $this->add(static::transformEmptyValuesToNull($this->getValues())); + $message = $this->translate('Resource "%s" has been successfully created'); + } else { // edit existing resource + $this->edit($resource, static::transformEmptyValuesToNull($this->getValues())); + $message = $this->translate('Resource "%s" has been successfully changed'); + } + } catch (InvalidArgumentException $e) { + Notification::error($e->getMessage()); + return false; + } + + if ($this->save()) { + Notification::success(sprintf($message, $this->getElement('name')->getValue())); + } else { + return false; + } + } + + /** + * Populate the form in case a resource is being edited + * + * @see Form::onRequest() + * + * @throws ConfigurationError In case the backend name is missing in the request or is invalid + */ + public function onRequest() + { + $resource = $this->request->getQuery('resource'); + if ($resource !== null) { + if ($resource === '') { + throw new ConfigurationError($this->translate('Resource name missing')); + } elseif (! $this->config->hasSection($resource)) { + throw new ConfigurationError($this->translate('Unknown resource provided')); + } + $configValues = $this->config->getSection($resource)->toArray(); + $configValues['name'] = $resource; + $this->populate($configValues); + foreach ($this->getElements() as $element) { + if ($element->getType() === 'Zend_Form_Element_Password' && strlen($element->getValue())) { + $element->setValue(static::$dummyPassword); + } + } + } + } + + /** + * Return a checkbox to be displayed at the beginning of the form + * which allows the user to skip the connection validation + * + * @return Zend_Form_Element + */ + protected function getForceCreationCheckbox() + { + return $this->createElement( + 'checkbox', + 'force_creation', + array( + 'order' => 0, + 'ignore' => true, + 'label' => $this->translate('Force Changes'), + 'description' => $this->translate('Check this box to enforce changes without connectivity validation') + ) + ); + } + + /** + * @see Form::createElemeents() + */ + public function createElements(array $formData) + { + $resourceType = isset($formData['type']) ? $formData['type'] : 'db'; + + $resourceTypes = array( + 'file' => $this->translate('File'), + 'ssh' => $this->translate('SSH Identity'), + ); + if ($resourceType === 'ldap' || Platform::hasLdapSupport()) { + $resourceTypes['ldap'] = 'LDAP'; + } + if ($resourceType === 'db' || Platform::hasDatabaseSupport()) { + $resourceTypes['db'] = $this->translate('SQL Database'); + } + + $this->addElement( + 'select', + 'type', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Resource Type'), + 'description' => $this->translate('The type of resource'), + 'multiOptions' => $resourceTypes, + 'value' => $resourceType + ) + ); + + if (isset($formData['force_creation']) && $formData['force_creation']) { + // In case another error occured and the checkbox was displayed before + $this->addElement($this->getForceCreationCheckbox()); + } + + $this->addElements($this->getResourceForm($resourceType)->createElements($formData)->getElements()); + } + + /** + * Create a resource by using the given form's values and return its inspection results + * + * @param Form $form + * + * @return ?Inspection + */ + public static function inspectResource(Form $form) + { + if ($form->getValue('type') !== 'ssh') { + $resource = ResourceFactory::createResource(new ConfigObject($form->getValues())); + if ($resource instanceof Inspectable) { + return $resource->inspect(); + } + } + } + + /** + * Run the configured resource's inspection checks and show the result, if necessary + * + * This will only run any validation if the user pushed the 'resource_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if ($this->getElement('resource_validation')->isChecked() && parent::isValid($formData)) { + $inspection = static::inspectResource($this); + if ($inspection !== null) { + $join = function ($e) use (&$join) { + return is_array($e) ? join("\n", array_map($join, $e)) : $e; + }; + $this->addElement( + 'note', + 'inspection_output', + array( + 'order' => 0, + 'value' => '<strong>' . $this->translate('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'pre', 'class' => 'log-output')), + ) + ) + ); + + if ($inspection->hasError()) { + $this->warning(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } + + return true; + } + + /** + * Add a submit button to this form and one to manually validate the configuration + * + * Calls parent::addSubmitButton() to add the submit button. + * + * @return $this + */ + public function addSubmitButton() + { + parent::addSubmitButton() + ->getElement('btn_submit') + ->setDecorators(array('ViewHelper')); + + $this->addElement( + 'submit', + 'resource_validation', + array( + 'ignore' => true, + 'label' => $this->translate('Validate Configuration'), + 'data-progress-label' => $this->translate('Validation In Progress'), + 'decorators' => array('ViewHelper') + ) + ); + + $this->setAttrib('data-progress-element', 'resource-progress'); + $this->addElement( + 'note', + 'resource-progress', + array( + 'decorators' => array( + 'ViewHelper', + array('Spinner', array('id' => 'resource-progress')) + ) + ) + ); + + $this->addDisplayGroup( + array('btn_submit', 'resource_validation', 'resource-progress'), + 'submit_validation', + array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ) + ) + ); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + $resource = $this->request->getQuery('resource'); + if ($resource !== null && $this->config->hasSection($resource)) { + $resourceConfig = $this->config->getSection($resource)->toArray(); + foreach ($this->getElements() as $element) { + if ($element->getType() === 'Zend_Form_Element_Password') { + $name = $element->getName(); + if (isset($values[$name]) && $values[$name] === static::$dummyPassword) { + if (isset($resourceConfig[$name])) { + $values[$name] = $resourceConfig[$name]; + } else { + unset($values[$name]); + } + } + } + } + } + + return $values; + } + + /** + * {@inheritDoc} + */ + protected function writeConfig(Config $config) + { + parent::writeConfig($config); + if ($this->updatedAppConfig !== null) { + $this->updatedAppConfig->saveIni(); + } + } +} diff --git a/application/forms/Config/User/CreateMembershipForm.php b/application/forms/Config/User/CreateMembershipForm.php new file mode 100644 index 0000000..6c74c8c --- /dev/null +++ b/application/forms/Config/User/CreateMembershipForm.php @@ -0,0 +1,192 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\User; + +use Exception; +use Icinga\Application\Logger; +use Icinga\Data\DataArray\ArrayDatasource; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form for creating one or more group memberships + */ +class CreateMembershipForm extends Form +{ + /** + * The user group backends to fetch groups from + * + * Each backend must implement the Icinga\Data\Extensible and Icinga\Data\Selectable interface. + * + * @var array + */ + protected $backends; + + /** + * The username to create memberships for + * + * @var string + */ + protected $userName; + + /** + * Set the user group backends to fetch groups from + * + * @param array $backends + * + * @return $this + */ + public function setBackends($backends) + { + $this->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; + $groupName = 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 0000000..fb2ef4d --- /dev/null +++ b/application/forms/Config/User/UserForm.php @@ -0,0 +1,210 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\User; + +use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; +use Icinga\Web\Notification; + +class UserForm extends RepositoryForm +{ + /** + * Create and add elements to this form to insert or update a user + * + * @param array $formData The data sent by the user + */ + protected function createInsertElements(array $formData) + { + $this->addElement( + 'checkbox', + 'is_active', + array( + '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( + 'password', + '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( + 'password', + 'password', + array( + 'description' => $this->translate('Leave empty for not updating the user\'s password'), + '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); + // before checking if password values is empty + // we have to check that the password field is set + // otherwise an error is thrown + if (isset($values['password']) && ! $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')); + $this->setAttrib('class', 'icinga-controls'); + } + + /** + * 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()); + } + } + + public function isValid($formData) + { + $valid = parent::isValid($formData); + + if ($valid && ConfigFormEventsHook::runIsValid($this) === false) { + foreach (ConfigFormEventsHook::getLastErrors() as $msg) { + $this->error($msg); + } + + $valid = false; + } + + return $valid; + } + + public function onSuccess() + { + if (parent::onSuccess() === false) { + return false; + } + + if (ConfigFormEventsHook::runOnSuccess($this) === false) { + Notification::error($this->translate( + 'Configuration successfully stored. Though, one or more module hooks failed to run.' + . ' See logs for details' + )); + } + } +} diff --git a/application/forms/Config/UserBackend/DbBackendForm.php b/application/forms/Config/UserBackend/DbBackendForm.php new file mode 100644 index 0000000..693ea14 --- /dev/null +++ b/application/forms/Config/UserBackend/DbBackendForm.php @@ -0,0 +1,82 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserBackend; + +use Icinga\Web\Form; + +/** + * Form class for adding/modifying database user backends + */ +class DbBackendForm extends Form +{ + /** + * The database resource names the user can choose from + * + * @var array + */ + protected $resources; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_authbackend_db'); + } + + /** + * Set the resource names the user can choose from + * + * @param array $resources The resources to choose from + * + * @return $this + */ + public function setResources(array $resources) + { + $this->resources = $resources; + 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 authentication provider that is used to differentiate it from others' + ) + ) + ); + $this->addElement( + 'select', + 'resource', + array( + 'required' => true, + 'label' => $this->translate('Database Connection'), + 'description' => $this->translate( + 'The database connection to use for authenticating with this provider' + ), + 'multiOptions' => !empty($this->resources) + ? array_combine($this->resources, $this->resources) + : array() + ) + ); + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, + 'value' => 'db' + ) + ); + } +} diff --git a/application/forms/Config/UserBackend/ExternalBackendForm.php b/application/forms/Config/UserBackend/ExternalBackendForm.php new file mode 100644 index 0000000..f4a4639 --- /dev/null +++ b/application/forms/Config/UserBackend/ExternalBackendForm.php @@ -0,0 +1,83 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserBackend; + +use Zend_Validate_Callback; +use Icinga\Web\Form; + +/** + * Form class for adding/modifying user backends of type "external" + */ +class ExternalBackendForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_authbackend_external'); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Backend Name'), + 'description' => $this->translate( + 'The name of this authentication provider that is used to differentiate it from others' + ) + ) + ); + $callbackValidator = new Zend_Validate_Callback(function ($value) { + return @preg_match($value, '') !== false; + }); + $callbackValidator->setMessage( + $this->translate('"%value%" is not a valid regular expression.'), + Zend_Validate_Callback::INVALID_VALUE + ); + $this->addElement( + 'text', + 'strip_username_regexp', + array( + 'label' => $this->translate('Filter Pattern'), + 'description' => $this->translate( + 'The filter to use to strip specific parts off from usernames.' + . ' Leave empty if you do not want to strip off anything.' + ), + 'requirement' => $this->translate('The filter pattern must be a valid regular expression.'), + 'validators' => array($callbackValidator) + ) + ); + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, + 'value' => 'external' + ) + ); + + return $this; + } + + /** + * Validate the configuration by creating a backend and requesting the user count + * + * Returns always true as backends of type "external" are just "passive" backends. + * + * @param Form $form The form to fetch the configuration values from + * + * @return bool Whether validation succeeded or not + */ + public static function isValidUserBackend(Form $form) + { + return true; + } +} diff --git a/application/forms/Config/UserBackend/LdapBackendForm.php b/application/forms/Config/UserBackend/LdapBackendForm.php new file mode 100644 index 0000000..e7804cc --- /dev/null +++ b/application/forms/Config/UserBackend/LdapBackendForm.php @@ -0,0 +1,414 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserBackend; + +use Exception; +use Icinga\Data\ResourceFactory; +use Icinga\Protocol\Ldap\LdapCapabilities; +use Icinga\Protocol\Ldap\LdapConnection; +use Icinga\Protocol\Ldap\LdapException; +use Icinga\Web\Form; + +/** + * Form class for adding/modifying LDAP user backends + */ +class LdapBackendForm extends Form +{ + /** + * The ldap resource names the user can choose from + * + * @var array + */ + protected $resources; + + /** + * Default values for the form elements + * + * @var string[] + */ + protected $suggestions = array(); + + /** + * Cache for {@link getLdapCapabilities()} + * + * @var LdapCapabilities + */ + protected $ldapCapabilities; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_authbackend_ldap'); + } + + /** + * Set the resource names the user can choose from + * + * @param array $resources The resources to choose from + * + * @return $this + */ + public function setResources(array $resources) + { + $this->resources = $resources; + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + $isAd = isset($formData['type']) ? $formData['type'] === 'msldap' : false; + + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Backend Name'), + 'description' => $this->translate( + 'The name of this authentication provider that is used to differentiate it from others.' + ), + 'value' => $this->getSuggestion('name') + ) + ); + $this->addElement( + 'select', + 'resource', + array( + 'required' => true, + 'label' => $this->translate('LDAP Connection'), + 'description' => $this->translate( + 'The LDAP connection to use for authenticating with this provider.' + ), + 'multiOptions' => !empty($this->resources) + ? array_combine($this->resources, $this->resources) + : array(), + 'value' => $this->getSuggestion('resource') + ) + ); + + if (! $isAd && !empty($this->resources)) { + $this->addElement( + 'button', + 'discovery_btn', + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => 'discovery_btn', + 'label' => $this->translate('Discover', 'A button to discover LDAP capabilities'), + 'title' => $this->translate( + 'Push to fill in the chosen connection\'s default settings.' + ), + 'decorators' => array( + array('ViewHelper', array('separator' => '')), + array('Spinner'), + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ), + 'formnovalidate' => 'formnovalidate' + ) + ); + } + + if ($isAd) { + // ActiveDirectory defaults + $userClass = 'user'; + $filter = '!(objectClass=computer)'; + $userNameAttribute = 'sAMAccountName'; + } else { + // OpenLDAP defaults + $userClass = 'inetOrgPerson'; + $filter = null; + $userNameAttribute = 'uid'; + } + + $this->addElement( + 'text', + 'user_class', + array( + 'preserveDefault' => true, + 'required' => ! $isAd, + 'ignore' => $isAd, + 'disabled' => $isAd ?: null, + 'label' => $this->translate('LDAP User Object Class'), + 'description' => $this->translate('The object class used for storing users on the LDAP server.'), + 'value' => $this->getSuggestion('user_class', $userClass) + ) + ); + $this->addElement( + 'text', + 'filter', + array( + 'preserveDefault' => true, + 'allowEmpty' => true, + 'value' => $this->getSuggestion('filter', $filter), + 'label' => $this->translate('LDAP Filter'), + 'description' => $this->translate( + 'An additional filter to use when looking up users using the specified connection. ' + . 'Leave empty to not to use any additional filter rules.' + ), + 'requirement' => $this->translate( + 'The filter needs to be expressed as standard LDAP expression.' + . ' (e.g. &(foo=bar)(bar=foo) or foo=bar)' + ), + 'validators' => array( + array( + 'Callback', + false, + array( + 'callback' => function ($v) { + // This is not meant to be a full syntax check. It will just + // ensure that we can safely strip unnecessary parentheses. + $v = trim($v); + return ! $v || $v[0] !== '(' || ( + strpos($v, ')(') !== false ? substr($v, -2) === '))' : substr($v, -1) === ')' + ); + }, + 'messages' => array( + 'callbackValue' => $this->translate('The filter is invalid. Please check your syntax.') + ) + ) + ) + ) + ) + ); + $this->addElement( + 'text', + 'user_name_attribute', + array( + 'preserveDefault' => true, + 'required' => ! $isAd, + 'ignore' => $isAd, + 'disabled' => $isAd ?: null, + 'label' => $this->translate('LDAP User Name Attribute'), + 'description' => $this->translate( + 'The attribute name used for storing the user name on the LDAP server.' + ), + 'value' => $this->getSuggestion('user_name_attribute', $userNameAttribute) + ) + ); + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, + 'value' => $this->getSuggestion('backend', $isAd ? 'msldap' : 'ldap') + ) + ); + $this->addElement( + 'text', + 'base_dn', + array( + 'preserveDefault' => true, + 'required' => false, + 'label' => $this->translate('LDAP Base DN'), + 'description' => $this->translate( + 'The path where users can be found on the LDAP server. Leave ' . + 'empty to select all users available using the specified connection.' + ), + 'value' => $this->getSuggestion('base_dn') + ) + ); + + $this->addElement( + 'text', + 'domain', + array( + 'label' => $this->translate('Domain'), + 'description' => $this->translate( + 'The domain the LDAP server is responsible for upon authentication.' + . ' Note that if you specify a domain here,' + . ' the LDAP backend only authenticates users who specify a domain upon login.' + . ' If the domain of the user matches the domain configured here, this backend is responsible for' + . ' authenticating the user based on the username without the domain part.' + . ' If your LDAP backend holds usernames with a domain part or if it is not necessary in your setup' + . ' to authenticate users based on their domains, leave this field empty.' + ), + 'preserveDefault' => true, + 'value' => $this->getSuggestion('domain') + ) + ); + + $this->addElement( + 'button', + 'btn_discover_domain', + array( + 'class' => 'control-button', + 'type' => 'submit', + 'value' => 'discovery_btn', + 'label' => $this->translate('Discover the domain'), + 'title' => $this->translate( + 'Push to disover and fill in the domain of the LDAP server.' + ), + 'decorators' => array( + array('ViewHelper', array('separator' => '')), + array('Spinner'), + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ), + 'formnovalidate' => 'formnovalidate' + ) + ); + } + + public function isValidPartial(array $formData) + { + $isAd = isset($formData['type']) && $formData['type'] === 'msldap'; + $baseDn = null; + $hasAdOid = false; + $discoverySuccessful = false; + + if (! $isAd && ! empty($this->resources) && isset($formData['discovery_btn']) + && $formData['discovery_btn'] === 'discovery_btn') { + $discoverySuccessful = true; + try { + $capabilities = $this->getLdapCapabilities($formData); + $baseDn = $capabilities->getDefaultNamingContext(); + $hasAdOid = $capabilities->isActiveDirectory(); + } catch (Exception $e) { + $this->warning(sprintf( + $this->translate('Failed to discover the chosen LDAP connection: %s'), + $e->getMessage() + )); + $discoverySuccessful = false; + } + } + + if ($discoverySuccessful) { + if ($isAd || $hasAdOid) { + // ActiveDirectory defaults + $userClass = 'user'; + $filter = '!(objectClass=computer)'; + $userNameAttribute = 'sAMAccountName'; + } else { + // OpenLDAP defaults + $userClass = 'inetOrgPerson'; + $filter = null; + $userNameAttribute = 'uid'; + } + + $formData['user_class'] = $userClass; + + if (! isset($formData['filter']) || $formData['filter'] === '') { + $formData['filter'] = $filter; + } + + $formData['user_name_attribute'] = $userNameAttribute; + + if ($baseDn !== null && (! isset($formData['base_dn']) || $formData['base_dn'] === '')) { + $formData['base_dn'] = $baseDn; + } + } + + if (isset($formData['btn_discover_domain']) && $formData['btn_discover_domain'] === 'discovery_btn') { + try { + $formData['domain'] = $this->discoverDomain($formData); + } catch (LdapException $e) { + $this->error($e->getMessage()); + } + } + + return parent::isValidPartial($formData); + } + + /** + * Get the LDAP capabilities of either the resource specified by the user or the default one + * + * @param string[] $formData + * + * @return LdapCapabilities + */ + protected function getLdapCapabilities(array $formData) + { + if ($this->ldapCapabilities === null) { + $this->ldapCapabilities = ResourceFactory::create( + isset($formData['resource']) ? $formData['resource'] : reset($this->resources) + )->bind()->getCapabilities(); + } + + return $this->ldapCapabilities; + } + + /** + * Discover the domain the LDAP server is responsible for + * + * @param string[] $formData + * + * @return string + */ + protected function discoverDomain(array $formData) + { + $cap = $this->getLdapCapabilities($formData); + + if ($cap->isActiveDirectory()) { + $netBiosName = $cap->getNetBiosName(); + if ($netBiosName !== null) { + return $netBiosName; + } + } + + return $this->defaultNamingContextToFQDN($cap); + } + + /** + * Get the default naming context as FQDN + * + * @param LdapCapabilities $cap + * + * @return string|null + */ + protected function defaultNamingContextToFQDN(LdapCapabilities $cap) + { + $defaultNamingContext = $cap->getDefaultNamingContext(); + if ($defaultNamingContext !== null) { + $validationMatches = array(); + if (preg_match('/\bdc=[^,]+(?:,dc=[^,]+)*$/', strtolower($defaultNamingContext), $validationMatches)) { + $splitMatches = array(); + preg_match_all('/dc=([^,]+)/', $validationMatches[0], $splitMatches); + return implode('.', $splitMatches[1]); + } + } + } + + /** + * Get the default values for the form elements + * + * @return string[] + */ + public function getSuggestions() + { + return $this->suggestions; + } + + /** + * Get the default value for the given form element or the given default + * + * @param string $element + * @param string $default + * + * @return string + */ + public function getSuggestion($element, $default = null) + { + return isset($this->suggestions[$element]) ? $this->suggestions[$element] : $default; + } + + /** + * Set the default values for the form elements + * + * @param string[] $suggestions + * + * @return $this + */ + public function setSuggestions(array $suggestions) + { + $this->suggestions = $suggestions; + + return $this; + } +} diff --git a/application/forms/Config/UserBackendConfigForm.php b/application/forms/Config/UserBackendConfigForm.php new file mode 100644 index 0000000..fdca657 --- /dev/null +++ b/application/forms/Config/UserBackendConfigForm.php @@ -0,0 +1,482 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config; + +use InvalidArgumentException; +use Icinga\Application\Config; +use Icinga\Authentication\User\UserBackend; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Forms\ConfigForm; +use Icinga\Forms\Config\UserBackend\ExternalBackendForm; +use Icinga\Forms\Config\UserBackend\DbBackendForm; +use Icinga\Forms\Config\UserBackend\LdapBackendForm; +use Icinga\Web\Form; + +/** + * Form for managing user backends + */ +class UserBackendConfigForm extends ConfigForm +{ + /** + * The available user backend resources split by type + * + * @var array + */ + protected $resources; + + /** + * The backend to load when displaying the form for the first time + * + * @var string + */ + protected $backendToLoad; + + /** + * The loaded custom backends list + * + * @var array + */ + protected $customBackends; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_authbackend'); + $this->setSubmitLabel($this->translate('Save Changes')); + $this->setValidatePartial(true); + $this->customBackends = UserBackend::getCustomBackendConfigForms(); + } + + /** + * Set the resource configuration to use + * + * @param Config $resourceConfig The resource configuration + * + * @return $this + * + * @throws ConfigurationError In case there are no valid resources for authentication available + */ + public function setResourceConfig(Config $resourceConfig) + { + $resources = array(); + foreach ($resourceConfig as $name => $resource) { + if (in_array($resource->type, array('db', 'ldap'))) { + $resources[$resource->type][] = $name; + } + } + + if (empty($resources)) { + $externalBackends = $this->config->toArray(); + array_walk( + $externalBackends, + function (&$authBackendCfg) { + if (! isset($authBackendCfg['backend']) || $authBackendCfg['backend'] !== 'external') { + $authBackendCfg = null; + } + } + ); + if (count(array_filter($externalBackends)) > 0 && ( + $this->backendToLoad === null || !isset($externalBackends[$this->backendToLoad]) + )) { + throw new ConfigurationError($this->translate( + 'Could not find any valid user backend resources.' + . ' Please configure a resource for authentication first.' + )); + } + } + + $this->resources = $resources; + return $this; + } + + /** + * Return a form object for the given backend type + * + * @param string $type The backend type for which to return a form + * + * @return Form + * + * @throws InvalidArgumentException In case the given backend type is invalid + */ + public function getBackendForm($type) + { + switch ($type) { + case 'db': + $form = new DbBackendForm(); + $form->setResources(isset($this->resources['db']) ? $this->resources['db'] : array()); + break; + case 'ldap': + case 'msldap': + $form = new LdapBackendForm(); + $form->setResources(isset($this->resources['ldap']) ? $this->resources['ldap'] : array()); + break; + case 'external': + $form = new ExternalBackendForm(); + break; + default: + if (isset($this->customBackends[$type])) { + return new $this->customBackends[$type](); + } + + throw new InvalidArgumentException( + sprintf($this->translate('Invalid backend type "%s" provided'), $type) + ); + } + + return $form; + } + + /** + * 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 backend called "%s" found', $name); + } + + $this->backendToLoad = $name; + return $this; + } + + /** + * Add a new user backend + * + * The backend to add is identified by the array-key `name'. + * + * @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( + $this->translate('A user backend with the name "%s" does already exist'), + $backendName + ); + } + + unset($data['name']); + $this->config->setSection($backendName, $data); + return $this; + } + + /** + * Edit a user 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 backend called "%s" found', $name); + } + + $backendConfig = $this->config->getSection($name); + if (isset($data['name'])) { + if ($data['name'] !== $name) { + $this->config->removeSection($name); + $name = $data['name']; + } + + unset($data['name']); + } + + $backendConfig->merge($data); + $this->config->setSection($name, $backendConfig); + return $this; + } + + /** + * Remove a user backend + * + * @param string $name + * + * @return $this + */ + public function delete($name) + { + $this->config->removeSection($name); + return $this; + } + + /** + * 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 + * + * @return $this + * + * @throws NotFoundError In case no backend with the given name is found + */ + public function move($name, $position) + { + if (! $this->config->hasSection($name)) { + throw new NotFoundError('No user backend called "%s" found', $name); + } + + $backendOrder = $this->config->keys(); + array_splice($backendOrder, array_search($name, $backendOrder), 1); + array_splice($backendOrder, $position, 0, $name); + + $newConfig = array(); + foreach ($backendOrder as $backendName) { + $newConfig[$backendName] = $this->config->getSection($backendName); + } + + $config = Config::fromArray($newConfig); + $this->config = $config->setConfigFile($this->config->getConfigFile()); + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + $backendTypes = array(); + $backendType = isset($formData['type']) ? $formData['type'] : null; + + if (isset($this->resources['db'])) { + $backendTypes['db'] = $this->translate('Database'); + } + if (isset($this->resources['ldap'])) { + $backendTypes['ldap'] = 'LDAP'; + $backendTypes['msldap'] = 'ActiveDirectory'; + } + + $externalBackends = array_filter( + $this->config->toArray(), + function ($authBackendCfg) { + return isset($authBackendCfg['backend']) && $authBackendCfg['backend'] === 'external'; + } + ); + if ($backendType === 'external' || empty($externalBackends)) { + $backendTypes['external'] = $this->translate('External'); + } + + $customBackendTypes = array_keys($this->customBackends); + $backendTypes += array_combine($customBackendTypes, $customBackendTypes); + + if ($backendType === null) { + $backendType = key($backendTypes); + } + + $this->addElement( + 'select', + 'type', + array( + 'ignore' => true, + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Backend Type'), + 'description' => $this->translate( + 'The type of the resource to use for this authenticaton provider' + ), + 'multiOptions' => $backendTypes + ) + ); + + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + // In case another error occured and the checkbox was displayed before + $this->addSkipValidationCheckbox(); + } + + $this->addSubForm($this->getBackendForm($backendType)->create($formData), 'backend_form'); + } + + /** + * Populate the configuration of the backend to load + */ + public function onRequest() + { + if ($this->backendToLoad) { + $data = $this->config->getSection($this->backendToLoad)->toArray(); + $data['name'] = $this->backendToLoad; + $data['type'] = $data['backend']; + $this->populate($data); + } + } + + /** + * Return whether the given values are valid + * + * @param array $formData The data to validate + * + * @return bool + */ + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + if (($el = $this->getElement('skip_validation')) === null || false === $el->isChecked()) { + $inspection = static::inspectUserBackend($this); + if ($inspection && $inspection->hasError()) { + $this->error($inspection->getError()); + if ($el === null) { + $this->addSkipValidationCheckbox(); + } + + return false; + } + } + + return true; + } + + /** + * Create a user backend by using the given form's values and return its inspection results + * + * Returns null for non-inspectable backends. + * + * @param Form $form + * + * @return Inspection|null + */ + public static function inspectUserBackend(Form $form) + { + $backend = UserBackend::create(null, new ConfigObject($form->getValues())); + if ($backend instanceof Inspectable) { + return $backend->inspect(); + } + } + + /** + * Add a checkbox to the form by which the user can skip the connection validation + */ + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'order' => 0, + 'ignore' => true, + 'label' => $this->translate('Skip Validation'), + 'description' => $this->translate( + 'Check this box to enforce changes without validating that authentication is possible.' + ) + ) + ); + } + + /** + * Run the configured backend's inspection checks and show the result, if necessary + * + * This will only run any validation if the user pushed the 'backend_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if (! parent::isValidPartial($formData)) { + return false; + } + + if ($this->getElement('backend_validation')->isChecked() && parent::isValid($formData)) { + $inspection = static::inspectUserBackend($this); + if ($inspection !== null) { + $join = function ($e) use (&$join) { + return is_array($e) ? join("\n", array_map($join, $e)) : $e; + }; + $this->addElement( + 'note', + 'inspection_output', + array( + 'order' => 0, + 'value' => '<strong>' . $this->translate('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'pre', 'class' => 'log-output')), + ) + ) + ); + + if ($inspection->hasError()) { + $this->warning(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } + + return true; + } + + /** + * Add a submit button to this form and one to manually validate the configuration + * + * Calls parent::addSubmitButton() to add the submit button. + * + * @return $this + */ + public function addSubmitButton() + { + parent::addSubmitButton() + ->getElement('btn_submit') + ->setDecorators(array('ViewHelper')); + + $this->addElement( + 'submit', + 'backend_validation', + array( + 'ignore' => true, + 'label' => $this->translate('Validate Configuration'), + 'data-progress-label' => $this->translate('Validation In Progress'), + 'decorators' => array('ViewHelper') + ) + ); + $this->addDisplayGroup( + array('btn_submit', 'backend_validation'), + 'submit_validation', + array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ) + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/UserBackendReorderForm.php b/application/forms/Config/UserBackendReorderForm.php new file mode 100644 index 0000000..019c032 --- /dev/null +++ b/application/forms/Config/UserBackendReorderForm.php @@ -0,0 +1,86 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config; + +use Icinga\Application\Config; +use Icinga\Forms\ConfigForm; +use Icinga\Exception\NotFoundError; +use Icinga\Web\Notification; + +class UserBackendReorderForm extends ConfigForm +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_reorder_authbackend'); + $this->setViewScript('form/reorder-authbackend.phtml'); + } + + /** + * Return the ordered backend names + * + * @return array + */ + public function getBackendOrder() + { + return $this->config->keys(); + } + + /** + * Return the ordered backend configuration + * + * @return Config + */ + public function getConfig() + { + return $this->config; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + // This adds just a dummy element to be able to utilize Form::getValue as part of onSuccess() + $this->addElement('hidden', 'backend_newpos'); + } + + /** + * Update the user backend order and save the configuration + */ + public function onSuccess() + { + $newPosData = $this->getValue('backend_newpos'); + if ($newPosData) { + $configForm = $this->getConfigForm(); + list($backendName, $position) = explode('|', $newPosData, 2); + + try { + if ($configForm->move($backendName, $position)->save()) { + Notification::success($this->translate('Authentication order updated')); + } else { + return false; + } + } catch (NotFoundError $_) { + Notification::error(sprintf($this->translate('User backend "%s" not found'), $backendName)); + } + } + } + + /** + * Return the config form for user backends + * + * @return ConfigForm + */ + protected function getConfigForm() + { + $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 0000000..cda9d52 --- /dev/null +++ b/application/forms/Config/UserGroup/AddMemberForm.php @@ -0,0 +1,183 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserGroup; + +use Exception; +use Icinga\Data\Extensible; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Selectable; +use Icinga\Exception\NotFoundError; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form for adding one or more group members + */ +class AddMemberForm extends Form +{ + /** + * The data source to fetch users from + * + * @var Selectable + */ + protected $ds; + + /** + * The user group backend to use + * + * @var Extensible + */ + protected $backend; + + /** + * The group to add members for + * + * @var string + */ + protected $groupName; + + /** + * Set the data source to fetch users from + * + * @param Selectable $ds + * + * @return $this + */ + public function setDataSource(Selectable $ds) + { + $this->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; + $userName = 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 0000000..daea8de --- /dev/null +++ b/application/forms/Config/UserGroup/DbUserGroupBackendForm.php @@ -0,0 +1,79 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserGroup; + +use Icinga\Data\ResourceFactory; +use Icinga\Web\Form; + +/** + * Form for managing database user group backends + */ +class DbUserGroupBackendForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_dbusergroupbackend'); + } + + /** + * 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' + ) + ) + ); + + $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) + ) + ); + + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, // Prevents the element from being submitted, see #7717 + 'value' => 'db' + ) + ); + } + + /** + * 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/LdapUserGroupBackendForm.php b/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php new file mode 100644 index 0000000..10c069a --- /dev/null +++ b/application/forms/Config/UserGroup/LdapUserGroupBackendForm.php @@ -0,0 +1,370 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserGroup; + +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\UserGroup\LdapUserGroupBackend; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Protocol\Ldap\LdapConnection; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form for managing LDAP user group backends + */ +class LdapUserGroupBackendForm extends Form +{ + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_ldapusergroupbackend'); + } + + /** + * 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' + ) + ) + ); + + $resourceNames = $this->getLdapResourceNames(); + $this->addElement( + 'select', + 'resource', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('LDAP Connection'), + 'description' => $this->translate('The LDAP connection to use for this backend.'), + 'multiOptions' => array_combine($resourceNames, $resourceNames) + ) + ); + $resource = ResourceFactory::create( + isset($formData['resource']) && in_array($formData['resource'], $resourceNames) + ? $formData['resource'] + : $resourceNames[0] + ); + + $userBackendNames = $this->getLdapUserBackendNames($resource); + if (! empty($userBackendNames)) { + $userBackends = array_combine($userBackendNames, $userBackendNames); + $userBackends['none'] = $this->translate('None', 'usergroupbackend.ldap.user_backend'); + } else { + $userBackends = array('none' => $this->translate('None', 'usergroupbackend.ldap.user_backend')); + } + $this->addElement( + 'select', + 'user_backend', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('User Backend'), + 'description' => $this->translate('The user backend to link with this user group backend.'), + 'multiOptions' => $userBackends + ) + ); + + $groupBackend = new LdapUserGroupBackend($resource); + if ($formData['type'] === 'ldap') { + $defaults = $groupBackend->getOpenLdapDefaults(); + $groupConfigDisabled = $userConfigDisabled = null; // MUST BE null, do NOT change this to false! + } else { // $formData['type'] === 'msldap' + $defaults = $groupBackend->getActiveDirectoryDefaults(); + $groupConfigDisabled = $userConfigDisabled = true; + } + + if ($formData['type'] === 'msldap') { + $this->addElement( + 'checkbox', + 'nested_group_search', + array( + 'description' => $this->translate( + 'Check this box for nested group search in Active Directory based on the user' + ), + 'label' => $this->translate('Nested Group Search') + ) + ); + } else { + // This is required to purge already present options + $this->addElement('hidden', 'nested_group_search', array('disabled' => true)); + } + + $this->createGroupConfigElements($defaults, $groupConfigDisabled); + if (count($userBackends) === 1 || (isset($formData['user_backend']) && $formData['user_backend'] === 'none')) { + $this->createUserConfigElements($defaults, $userConfigDisabled); + } else { + $this->createHiddenUserConfigElements(); + } + + $this->addElement( + 'hidden', + 'backend', + array( + 'disabled' => true, // Prevents the element from being submitted, see #7717 + 'value' => $formData['type'] + ) + ); + } + + /** + * Create and add all elements to this form required for the group configuration + * + * @param ConfigObject $defaults + * @param null|bool $disabled + */ + protected function createGroupConfigElements(ConfigObject $defaults, $disabled) + { + $this->addElement( + 'text', + 'group_class', + array( + 'preserveDefault' => true, + 'ignore' => $disabled, + 'disabled' => $disabled, + 'label' => $this->translate('LDAP Group Object Class'), + 'description' => $this->translate('The object class used for storing groups on the LDAP server.'), + 'value' => $defaults->group_class + ) + ); + $this->addElement( + 'text', + 'group_filter', + array( + 'preserveDefault' => true, + 'allowEmpty' => true, + 'label' => $this->translate('LDAP Group Filter'), + 'description' => $this->translate( + 'An additional filter to use when looking up groups using the specified connection. ' + . 'Leave empty to not to use any additional filter rules.' + ), + 'requirement' => $this->translate( + 'The filter needs to be expressed as standard LDAP expression, without' + . ' outer parentheses. (e.g. &(foo=bar)(bar=foo) or foo=bar)' + ), + 'validators' => array( + array( + 'Callback', + false, + array( + 'callback' => function ($v) { + return strpos($v, '(') !== 0; + }, + 'messages' => array( + 'callbackValue' => $this->translate('The filter must not be wrapped in parantheses.') + ) + ) + ) + ), + 'value' => $defaults->group_filter + ) + ); + $this->addElement( + 'text', + 'group_name_attribute', + array( + 'preserveDefault' => true, + 'ignore' => $disabled, + 'disabled' => $disabled, + 'label' => $this->translate('LDAP Group Name Attribute'), + 'description' => $this->translate( + 'The attribute name used for storing a group\'s name on the LDAP server.' + ), + 'value' => $defaults->group_name_attribute + ) + ); + $this->addElement( + 'text', + 'group_member_attribute', + array( + 'preserveDefault' => true, + 'ignore' => $disabled, + 'disabled' => $disabled, + 'label' => $this->translate('LDAP Group Member Attribute'), + 'description' => $this->translate('The attribute name used for storing a group\'s members.'), + 'value' => $defaults->group_member_attribute + ) + ); + $this->addElement( + 'text', + 'base_dn', + array( + 'preserveDefault' => true, + 'label' => $this->translate('LDAP Group Base DN'), + 'description' => $this->translate( + 'The path where groups can be found on the LDAP server. Leave ' . + 'empty to select all users available using the specified connection.' + ), + 'value' => $defaults->base_dn + ) + ); + } + + /** + * Create and add all elements to this form required for the user configuration + * + * @param ConfigObject $defaults + * @param null|bool $disabled + */ + protected function createUserConfigElements(ConfigObject $defaults, $disabled) + { + $this->addElement( + 'text', + 'user_class', + array( + 'preserveDefault' => true, + 'ignore' => $disabled, + 'disabled' => $disabled, + 'label' => $this->translate('LDAP User Object Class'), + 'description' => $this->translate('The object class used for storing users on the LDAP server.'), + 'value' => $defaults->user_class + ) + ); + $this->addElement( + 'text', + 'user_filter', + array( + 'preserveDefault' => true, + 'allowEmpty' => true, + 'label' => $this->translate('LDAP User Filter'), + 'description' => $this->translate( + 'An additional filter to use when looking up users using the specified connection. ' + . 'Leave empty to not to use any additional filter rules.' + ), + 'requirement' => $this->translate( + 'The filter needs to be expressed as standard LDAP expression, without' + . ' outer parentheses. (e.g. &(foo=bar)(bar=foo) or foo=bar)' + ), + 'validators' => array( + array( + 'Callback', + false, + array( + 'callback' => function ($v) { + return strpos($v, '(') !== 0; + }, + 'messages' => array( + 'callbackValue' => $this->translate('The filter must not be wrapped in parantheses.') + ) + ) + ) + ), + 'value' => $defaults->user_filter + ) + ); + $this->addElement( + 'text', + 'user_name_attribute', + array( + 'preserveDefault' => true, + 'ignore' => $disabled, + 'disabled' => $disabled, + 'label' => $this->translate('LDAP User Name Attribute'), + 'description' => $this->translate( + 'The attribute name used for storing a user\'s name on the LDAP server.' + ), + 'value' => $defaults->user_name_attribute + ) + ); + $this->addElement( + 'text', + 'user_base_dn', + array( + 'preserveDefault' => true, + 'label' => $this->translate('LDAP User Base DN'), + 'description' => $this->translate( + 'The path where users can be found on the LDAP server. Leave ' . + 'empty to select all users available using the specified connection.' + ), + 'value' => $defaults->user_base_dn + ) + ); + $this->addElement( + 'text', + 'domain', + array( + 'label' => $this->translate('Domain'), + 'description' => $this->translate( + 'The domain the LDAP server is responsible for.' + ) + ) + ); + } + + /** + * Create and add all elements for the user configuration as hidden inputs + * + * This is required to purge already present options when unlinking a group backend with a user backend. + */ + protected function createHiddenUserConfigElements() + { + $this->addElement('hidden', 'user_class', array('disabled' => true)); + $this->addElement('hidden', 'user_filter', array('disabled' => true)); + $this->addElement('hidden', 'user_name_attribute', array('disabled' => true)); + $this->addElement('hidden', 'user_base_dn', array('disabled' => true)); + $this->addElement('hidden', 'domain', array('disabled' => true)); + } + + /** + * Return the names of all configured LDAP resources + * + * @return array + */ + protected function getLdapResourceNames() + { + $names = array(); + foreach (ResourceFactory::getResourceConfigs() as $name => $config) { + if (in_array(strtolower($config->type), array('ldap', 'msldap'))) { + $names[] = $name; + } + } + + if (empty($names)) { + Notification::error( + $this->translate('No LDAP resources available. Please configure an LDAP resource first.') + ); + $this->getResponse()->redirectAndExit('config/createresource'); + } + + return $names; + } + + /** + * Return the names of all configured LDAP user backends + * + * @param LdapConnection $resource + * + * @return array + */ + protected function getLdapUserBackendNames(LdapConnection $resource) + { + $names = array(); + foreach (UserBackend::getBackendConfigs() as $name => $config) { + if (in_array(strtolower($config->backend), array('ldap', 'msldap'))) { + $backendResource = ResourceFactory::create($config->resource); + if ($backendResource->getHostname() === $resource->getHostname() + && $backendResource->getPort() === $resource->getPort() + ) { + $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 0000000..9ee4032 --- /dev/null +++ b/application/forms/Config/UserGroup/UserGroupBackendForm.php @@ -0,0 +1,314 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserGroup; + +use Icinga\Authentication\UserGroup\UserGroupBackend; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Web\Form; +use InvalidArgumentException; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Forms\ConfigForm; + +/** + * Form for managing user group backends + */ +class UserGroupBackendForm extends ConfigForm +{ + protected $validatePartial = true; + + /** + * The backend to load when displaying the form for the first time + * + * @var string + */ + protected $backendToLoad; + + /** + * Known custom backends + * + * @var array + */ + protected $customBackends; + + /** + * Create a user group backend by using the given form's values and return its inspection results + * + * Returns null for non-inspectable backends. + * + * @param Form $form + * + * @return Inspection|null + */ + public static function inspectUserBackend(Form $form) + { + $backend = UserGroupBackend::create(null, new ConfigObject($form->getValues())); + if ($backend instanceof Inspectable) { + return $backend->inspect(); + } + } + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_usergroupbackend'); + $this->setSubmitLabel($this->translate('Save Changes')); + $this->customBackends = UserGroupBackend::getCustomBackendConfigForms(); + } + + /** + * Return a form object for the given backend type + * + * @param string $type The backend type for which to return a form + * + * @return Form + * + * @throws InvalidArgumentException In case the given backend type is invalid + */ + public function getBackendForm($type) + { + switch ($type) { + case 'db': + return new DbUserGroupBackendForm(); + case 'ldap': + case 'msldap': + return new LdapUserGroupBackendForm(); + default: + if (isset($this->customBackends[$type])) { + return new $this->customBackends[$type](); + } + + 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); + } + + $this->backendToLoad = $name; + return $this; + } + + /** + * Add a new user group backend + * + * The backend to add is identified by the array-key `name'. + * + * @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'])) { + if ($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) + { + $backendTypes = array( + 'db' => $this->translate('Database'), + 'ldap' => 'LDAP', + 'msldap' => 'ActiveDirectory' + ); + + $customBackendTypes = array_keys($this->customBackends); + $backendTypes += array_combine($customBackendTypes, $customBackendTypes); + + $backendType = isset($formData['type']) ? $formData['type'] : null; + if ($backendType === null) { + $backendType = key($backendTypes); + } + + $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 + ) + ); + + $this->addSubForm($this->getBackendForm($backendType)->create($formData), 'backend_form'); + } + + /** + * Populate the configuration of the backend to load + */ + public function onRequest() + { + if ($this->backendToLoad) { + $data = $this->config->getSection($this->backendToLoad)->toArray(); + $data['type'] = $data['backend']; + $data['name'] = $this->backendToLoad; + $this->populate($data); + } + } + + /** + * Run the configured backend's inspection checks and show the result, if necessary + * + * This will only run any validation if the user pushed the 'backend_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + $inspection = static::inspectUserBackend($this); + if ($inspection !== null) { + $join = function ($e) use (&$join) { + return is_array($e) ? join("\n", array_map($join, $e)) : $e; + }; + $this->addElement( + 'note', + 'inspection_output', + array( + 'order' => 0, + 'value' => '<strong>' . $this->translate('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'pre', 'class' => 'log-output')), + ) + ) + ); + + if ($inspection->hasError()) { + $this->warning(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } + + return true; + } + + /** + * Add a submit button to this form and one to manually validate the configuration + * + * Calls parent::addSubmitButton() to add the submit button. + * + * @return $this + */ + public function addSubmitButton() + { + parent::addSubmitButton() + ->getElement('btn_submit') + ->setDecorators(array('ViewHelper')); + + $this->addElement( + 'submit', + 'backend_validation', + array( + 'ignore' => true, + 'label' => $this->translate('Validate Configuration'), + 'data-progress-label' => $this->translate('Validation In Progress'), + 'decorators' => array('ViewHelper') + ) + ); + $this->addDisplayGroup( + array('btn_submit', 'backend_validation'), + 'submit_validation', + array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ) + ) + ); + + return $this; + } +} diff --git a/application/forms/Config/UserGroup/UserGroupForm.php b/application/forms/Config/UserGroup/UserGroupForm.php new file mode 100644 index 0000000..b944e97 --- /dev/null +++ b/application/forms/Config/UserGroup/UserGroupForm.php @@ -0,0 +1,158 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Config\UserGroup; + +use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\RepositoryForm; +use Icinga\Web\Notification; + +class UserGroupForm extends RepositoryForm +{ + /** + * Create and add elements to this form to insert or update a group + * + * @param array $formData The data sent by the user + */ + protected function createInsertElements(array $formData) + { + $this->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')); + $this->setAttrib('class', 'icinga-form icinga-controls'); + } + + /** + * 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()); + } + } + + public function isValid($formData) + { + $valid = parent::isValid($formData); + + if ($valid && ConfigFormEventsHook::runIsValid($this) === false) { + foreach (ConfigFormEventsHook::getLastErrors() as $msg) { + $this->error($msg); + } + + $valid = false; + } + + return $valid; + } + + public function onSuccess() + { + if (parent::onSuccess() === false) { + return false; + } + + if (ConfigFormEventsHook::runOnSuccess($this) === false) { + Notification::error($this->translate( + 'Configuration successfully stored. Though, one or more module hooks failed to run.' + . ' See logs for details' + )); + } + } +} diff --git a/application/forms/ConfigForm.php b/application/forms/ConfigForm.php new file mode 100644 index 0000000..8b0c5f9 --- /dev/null +++ b/application/forms/ConfigForm.php @@ -0,0 +1,192 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Exception; +use Zend_Form_Decorator_Abstract; +use Icinga\Application\Config; +use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Exception\ConfigurationError; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form base-class providing standard functionality for configuration forms + */ +class ConfigForm extends Form +{ + /** + * The configuration to work with + * + * @var Config + */ + protected $config; + + /** + * {@inheritdoc} + * + * Values from subforms are directly added to the returned values array instead of being grouped by the subforms' + * names. + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + foreach (array_keys($this->_subForms) as $name) { + // Zend returns values from subforms grouped by their names, but we want them flat + $values = array_merge($values, $values[$name]); + unset($values[$name]); + } + return $values; + } + + /** + * Set the configuration to use when populating the form or when saving the user's input + * + * @param Config $config The configuration to use + * + * @return $this + */ + public function setIniConfig(Config $config) + { + $this->config = $config; + return $this; + } + + public function isValid($formData) + { + $valid = parent::isValid($formData); + + if ($valid && ConfigFormEventsHook::runIsValid($this) === false) { + foreach (ConfigFormEventsHook::getLastErrors() as $msg) { + $this->error($msg); + } + + $valid = false; + } + + return $valid; + } + + public function onSuccess() + { + $sections = array(); + foreach (static::transformEmptyValuesToNull($this->getValues()) as $sectionAndPropertyName => $value) { + list($section, $property) = explode('_', $sectionAndPropertyName, 2); + $sections[$section][$property] = $value; + } + + foreach ($sections as $section => $config) { + if ($this->isEmptyConfig($config)) { + $this->config->removeSection($section); + } else { + $this->config->setSection($section, $config); + } + } + + if ($this->save()) { + Notification::success($this->translate('New configuration has successfully been stored')); + } else { + return false; + } + + if (ConfigFormEventsHook::runOnSuccess($this) === false) { + Notification::error($this->translate( + 'Configuration successfully stored. Though, one or more module hooks failed to run.' + . ' See logs for details' + )); + } + } + + public function onRequest() + { + $values = array(); + foreach ($this->config as $section => $properties) { + foreach ($properties as $name => $value) { + $values[$section . '_' . $name] = $value; + } + } + + $this->populate($values); + } + + /** + * Persist the current configuration to disk + * + * If an error occurs the user is shown a view describing the issue and displaying the raw INI configuration. + * + * @return bool Whether the configuration could be persisted + */ + public function save() + { + try { + $this->writeConfig($this->config); + } catch (ConfigurationError $e) { + $this->addError($e->getMessage()); + + return false; + } catch (Exception $e) { + $this->addDecorator('ViewScript', array( + 'viewModule' => 'default', + 'viewScript' => 'showConfiguration.phtml', + 'errorMessage' => $e->getMessage(), + 'configString' => $this->config, + 'filePath' => $this->config->getConfigFile(), + 'placement' => Zend_Form_Decorator_Abstract::PREPEND + )); + return false; + } + + return true; + } + + /** + * Write the configuration to disk + * + * @param Config $config + */ + protected function writeConfig(Config $config) + { + $config->saveIni(); + } + + /** + * Get whether the given config is empty or has only empty values + * + * @param array|Config $config + * + * @return bool + */ + protected function isEmptyConfig($config) + { + if ($config instanceof Config) { + $config = $config->toArray(); + } + + foreach ($config as $value) { + if ($value !== null) { + return false; + } + } + + return true; + } + + /** + * Transform all empty values of the given array to null + * + * @param array $values + * + * @return array + */ + public static function transformEmptyValuesToNull(array $values) + { + array_walk($values, function (&$v) { + if ($v === '' || $v === false || $v === array()) { + $v = null; + } + }); + + return $values; + } +} diff --git a/application/forms/ConfirmRemovalForm.php b/application/forms/ConfirmRemovalForm.php new file mode 100644 index 0000000..39fc661 --- /dev/null +++ b/application/forms/ConfirmRemovalForm.php @@ -0,0 +1,38 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Web\Form; + +/** + * Form for confirming removal of an object + */ +class ConfirmRemovalForm extends Form +{ + const DEFAULT_CLASSES = 'icinga-controls'; + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setName('form_confirm_removal'); + $this->getSubmitLabel() ?: $this->setSubmitLabel($this->translate('Confirm Removal')); + } + + /** + * {@inheritdoc} + */ + public function addSubmitButton() + { + parent::addSubmitButton(); + + if (($submit = $this->getElement('btn_submit')) !== null) { + $class = $submit->getAttrib('class'); + $submit->setAttrib('class', empty($class) ? 'autofocus' : $class . ' autofocus'); + } + + return $this; + } +} diff --git a/application/forms/Control/LimiterControlForm.php b/application/forms/Control/LimiterControlForm.php new file mode 100644 index 0000000..88adf4b --- /dev/null +++ b/application/forms/Control/LimiterControlForm.php @@ -0,0 +1,134 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Control; + +use Icinga\Web\Form; + +/** + * Limiter control form + */ +class LimiterControlForm extends Form +{ + /** + * CSS class for the limiter control + * + * @var string + */ + const CSS_CLASS_LIMITER = 'limiter-control icinga-controls inline'; + + /** + * Default limit + * + * @var int + */ + const DEFAULT_LIMIT = 50; + + /** + * Selectable default limits + * + * @var int[] + */ + public static $limits = array( + 10 => '10', + 25 => '25', + 50 => '50', + 100 => '100', + 500 => '500' + ); + + public static $defaultElementDecorators = [ + ['Label', ['tag' => 'span', 'separator' => '']], + ['ViewHelper', ['separator' => '']], + ]; + + /** + * Default limit for this instance + * + * @var int|null + */ + protected $defaultLimit; + + /** + * {@inheritdoc} + */ + public function init() + { + $this->setAttrib('class', static::CSS_CLASS_LIMITER); + } + + /** + * Get the default limit + * + * @return int + */ + public function getDefaultLimit() + { + return $this->defaultLimit !== null ? $this->defaultLimit : static::DEFAULT_LIMIT; + } + + /** + * Set the default limit + * + * @param int $defaultLimit + * + * @return $this + */ + public function setDefaultLimit($defaultLimit) + { + $defaultLimit = (int) $defaultLimit; + + if (! isset(static::$limits[$defaultLimit])) { + static::$limits[$defaultLimit] = $defaultLimit; + } + + $this->defaultLimit = $defaultLimit; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getRedirectUrl() + { + return $this->getRequest()->getUrl() + ->setParam('limit', $this->getElement('limit')->getValue()) + ->without('page'); + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $options = static::$limits; + $pageSize = (int) $this->getRequest()->getUrl()->getParam('limit', $this->getDefaultLimit()); + + if (! isset($options[$pageSize])) { + $options[$pageSize] = $pageSize; + } + + $this->addElement( + 'select', + 'limit', + array( + 'autosubmit' => true, + 'escape' => false, + 'label' => '#', + 'multiOptions' => $options, + 'value' => $pageSize + ) + ); + } + + /** + * Limiter control is always successful + * + * @return bool + */ + public function onSuccess() + { + return true; + } +} diff --git a/application/forms/Dashboard/DashletForm.php b/application/forms/Dashboard/DashletForm.php new file mode 100644 index 0000000..1af65a9 --- /dev/null +++ b/application/forms/Dashboard/DashletForm.php @@ -0,0 +1,171 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Dashboard; + +use Icinga\Web\Form; +use Icinga\Web\Form\Validator\InternalUrlValidator; +use Icinga\Web\Form\Validator\UrlValidator; +use Icinga\Web\Url; +use Icinga\Web\Widget\Dashboard; +use Icinga\Web\Widget\Dashboard\Dashlet; + +/** + * Form to add an url a dashboard pane + */ +class DashletForm extends Form +{ + /** + * @var Dashboard + */ + private $dashboard; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_dashboard_addurl'); + if (! $this->getSubmitLabel()) { + $this->setSubmitLabel($this->translate('Add To Dashboard')); + } + $this->setAction(Url::fromRequest()); + } + + /** + * Build AddUrl form elements + * + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $groupElements = array(); + $panes = array(); + + if ($this->dashboard) { + $panes = $this->dashboard->getPaneKeyTitleArray(); + } + + $sectionNameValidator = ['Callback', true, [ + 'callback' => function ($value) { + if (strpos($value, '[') === false && strpos($value, ']') === false) { + return true; + } + }, + 'messages' => [ + 'callbackValue' => $this->translate('Brackets ([, ]) cannot be used here') + ] + ]]; + + $this->addElement( + 'hidden', + 'org_pane', + array( + 'required' => false + ) + ); + + $this->addElement( + 'hidden', + 'org_dashlet', + array( + 'required' => false + ) + ); + + $this->addElement( + 'textarea', + 'url', + array( + 'required' => true, + 'label' => $this->translate('Url'), + 'description' => $this->translate( + 'Enter url to be loaded in the dashlet. You can paste the full URL, including filters.' + ), + 'validators' => array(new UrlValidator(), new InternalUrlValidator()) + ) + ); + $this->addElement( + 'text', + 'dashlet', + array( + 'required' => true, + 'label' => $this->translate('Dashlet Title'), + 'description' => $this->translate('Enter a title for the dashlet.'), + 'validators' => [$sectionNameValidator] + ) + ); + $this->addElement( + 'note', + 'note', + array( + 'decorators' => array( + array('HtmlTag', array('tag' => 'hr')) + ) + ) + ); + $this->addElement( + 'checkbox', + 'create_new_pane', + array( + 'autosubmit' => true, + 'required' => false, + 'label' => $this->translate('New dashboard'), + 'description' => $this->translate('Check this box if you want to add the dashlet to a new dashboard') + ) + ); + if (empty($panes) || ((isset($formData['create_new_pane']) && $formData['create_new_pane'] != false))) { + $this->addElement( + 'text', + 'pane', + array( + 'required' => true, + 'label' => $this->translate('New Dashboard Title'), + 'description' => $this->translate('Enter a title for the new dashboard'), + 'validators' => [$sectionNameValidator] + ) + ); + } else { + $this->addElement( + 'select', + 'pane', + array( + 'required' => true, + 'label' => $this->translate('Dashboard'), + 'multiOptions' => $panes, + 'description' => $this->translate('Select a dashboard you want to add the dashlet to') + ) + ); + } + } + + /** + * @param \Icinga\Web\Widget\Dashboard $dashboard + */ + public function setDashboard(Dashboard $dashboard) + { + $this->dashboard = $dashboard; + } + + /** + * @return \Icinga\Web\Widget\Dashboard + */ + public function getDashboard() + { + return $this->dashboard; + } + + /** + * @param Dashlet $dashlet + */ + public function load(Dashlet $dashlet) + { + $this->populate(array( + 'pane' => $dashlet->getPane()->getTitle(), + 'org_pane' => $dashlet->getPane()->getName(), + 'dashlet' => $dashlet->getTitle(), + 'org_dashlet' => $dashlet->getName(), + 'url' => $dashlet->getUrl()->getRelativeUrl() + )); + } +} diff --git a/application/forms/LdapDiscoveryForm.php b/application/forms/LdapDiscoveryForm.php new file mode 100644 index 0000000..5c7fc87 --- /dev/null +++ b/application/forms/LdapDiscoveryForm.php @@ -0,0 +1,34 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Web\Form; + +class LdapDiscoveryForm extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setName('form_ldap_discovery'); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'domain', + array( + 'label' => $this->translate('Search Domain'), + 'description' => $this->translate('Search this domain for records of available servers.'), + ) + ); + + return $this; + } +} diff --git a/application/forms/MigrationForm.php b/application/forms/MigrationForm.php new file mode 100644 index 0000000..c5d517f --- /dev/null +++ b/application/forms/MigrationForm.php @@ -0,0 +1,143 @@ +<?php + +/* Icinga Web 2 | (c) 2023 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Forms; + +use Icinga\Application\MigrationManager; +use ipl\Html\Attributes; +use ipl\Html\FormElement\CheckboxElement; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Validator\CallbackValidator; +use ipl\Web\Common\CsrfCounterMeasure; +use ipl\Web\Common\FormUid; +use ipl\Web\Compat\CompatForm; +use ipl\Web\FormDecorator\IcingaFormDecorator; +use PDOException; + +class MigrationForm extends CompatForm +{ + use CsrfCounterMeasure; + use FormUid; + use Translation; + + protected $defaultAttributes = [ + 'class' => ['icinga-form', 'migration-form', 'icinga-controls'], + 'name' => 'migration-form' + ]; + + /** @var bool Whether to allow changing the current database user and password */ + protected $renderDatabaseUserChange = false; + + public function hasBeenSubmitted(): bool + { + if (! $this->hasBeenSent()) { + return false; + } + + $pressedButton = $this->getPressedSubmitElement(); + + return $pressedButton && strpos($pressedButton->getName(), 'migrate-') !== false; + } + + public function setRenderDatabaseUserChange(bool $value = true): self + { + $this->renderDatabaseUserChange = $value; + + return $this; + } + + public function hasDefaultElementDecorator() + { + // The base implementation registers a decorator we don't want here + return false; + } + + protected function assemble(): void + { + $this->addHtml($this->createUidElement()); + + if ($this->renderDatabaseUserChange) { + $mm = MigrationManager::instance(); + $newDbSetup = new FieldsetElement('database_setup', ['required' => true]); + $newDbSetup + ->setDefaultElementDecorator(new IcingaFormDecorator()) + ->addElement('text', 'username', [ + 'required' => true, + 'label' => $this->translate('Username'), + 'description' => $this->translate( + 'A user which is able to create and/or alter the database schema.' + ) + ]) + ->addElement('password', 'password', [ + 'required' => true, + 'autocomplete' => 'new-password', + 'label' => $this->translate('Password'), + 'description' => $this->translate('The password for the database user defined above.'), + 'validators' => [ + new CallbackValidator(function ($_, CallbackValidator $validator) use ($mm, $newDbSetup): bool { + /** @var array<string, string> $values */ + $values = $this->getValue('database_setup'); + /** @var CheckboxElement $checkBox */ + $checkBox = $newDbSetup->getElement('grant_privileges'); + $canIssueGrants = $checkBox->isChecked(); + $elevationConfig = [ + 'username' => $values['username'], + 'password' => $values['password'] + ]; + + try { + if (! $mm->validateDatabasePrivileges($elevationConfig, $canIssueGrants)) { + $validator->addMessage(sprintf( + $this->translate( + 'The provided credentials cannot be used to execute "%s" SQL commands' + . ' and/or grant the missing privileges to other users.' + ), + implode(' ,', $mm->getRequiredDatabasePrivileges()) + )); + + return false; + } + } catch (PDOException $e) { + $validator->addMessage($e->getMessage()); + + return false; + } + + return true; + }) + ] + ]) + ->addElement('checkbox', 'grant_privileges', [ + 'required' => false, + 'label' => $this->translate('Grant Missing Privileges'), + 'description' => $this->translate( + 'Allows to automatically grant the required privileges to the database user specified' + . ' in the respective resource config. If you do not want to provide additional credentials' + . ' each time, you can enable this and Icinga Web will grant the active database user the' + . ' missing privileges.' + ) + ]); + + $this->addHtml( + new HtmlElement( + 'div', + Attributes::create(['class' => 'change-database-user-description']), + new HtmlElement('span', null, Text::create(sprintf( + $this->translate( + 'It seems that the currently used database user does not have the required privileges to' + . ' execute the %s SQL commands. Please provide an alternative user' + . ' that has the appropriate credentials to resolve this issue.' + ), + implode(', ', $mm->getRequiredDatabasePrivileges()) + ))) + ) + ); + + $this->addElement($newDbSetup); + } + } +} diff --git a/application/forms/Navigation/DashletForm.php b/application/forms/Navigation/DashletForm.php new file mode 100644 index 0000000..6575fd7 --- /dev/null +++ b/application/forms/Navigation/DashletForm.php @@ -0,0 +1,35 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Navigation; + +class DashletForm extends NavigationItemForm +{ + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'pane', + array( + 'required' => true, + 'label' => $this->translate('Pane'), + 'description' => $this->translate('The name of the dashboard pane in which to display this dashlet') + ) + ); + $this->addElement( + 'text', + 'url', + array( + 'required' => true, + 'label' => $this->translate('Url'), + 'description' => $this->translate( + 'The url to load in the dashlet. For external urls, make sure to prepend' + . ' an appropriate protocol identifier (e.g. http://example.tld)' + ) + ) + ); + } +} diff --git a/application/forms/Navigation/MenuItemForm.php b/application/forms/Navigation/MenuItemForm.php new file mode 100644 index 0000000..c9fa729 --- /dev/null +++ b/application/forms/Navigation/MenuItemForm.php @@ -0,0 +1,31 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Navigation; + +class MenuItemForm extends NavigationItemForm +{ + /** + * {@inheritdoc} + */ + protected $requiresParentSelection = true; + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + parent::createElements($formData); + + // Remove _self and _next as for menu entries only _main is valid + $this->getElement('target')->removeMultiOption('_self'); + $this->getElement('target')->removeMultiOption('_next'); + + $parentElement = $this->getParent()->getElement('parent'); + if ($parentElement !== null) { + $parentElement->setDescription($this->translate( + 'The parent menu to assign this menu entry to. Select "None" to make this a main menu entry' + )); + } + } +} diff --git a/application/forms/Navigation/NavigationConfigForm.php b/application/forms/Navigation/NavigationConfigForm.php new file mode 100644 index 0000000..0c4ae32 --- /dev/null +++ b/application/forms/Navigation/NavigationConfigForm.php @@ -0,0 +1,853 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Navigation; + +use InvalidArgumentException; +use Icinga\Application\Config; +use Icinga\Application\Logger; +use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; +use Icinga\Exception\IcingaException; +use Icinga\Exception\NotFoundError; +use Icinga\Exception\ProgrammingError; +use Icinga\Forms\ConfigForm; +use Icinga\User; +use Icinga\Util\StringHelper; +use Icinga\Web\Form; + +/** + * Form for managing navigation items + */ +class NavigationConfigForm extends ConfigForm +{ + /** + * The class namespace where to locate navigation type forms + * + * @var string + */ + const FORM_NS = 'Forms\\Navigation'; + + /** + * The secondary configuration to write + * + * This is always the reduced configuration and is only written to + * disk once the main configuration has been successfully written. + * + * @var Config + */ + protected $secondaryConfig; + + /** + * The navigation item to load when displaying the form for the first time + * + * @var string + */ + protected $itemToLoad; + + /** + * The user for whom to manage navigation items + * + * @var User + */ + protected $user; + + /** + * The user's navigation configuration + * + * @var Config + */ + protected $userConfig; + + /** + * The shared navigation configuration + * + * @var Config + */ + protected $shareConfig; + + /** + * The available navigation item types + * + * @var array + */ + protected $itemTypes; + + private $defaultUrl; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_navigation'); + $this->setSubmitLabel($this->translate('Save Changes')); + } + + /** + * Set the user for whom to manage navigation items + * + * @param User $user + * + * @return $this + */ + public function setUser(User $user) + { + $this->user = $user; + return $this; + } + + /** + * Return the user for whom to manage navigation items + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the user's navigation configuration + * + * @param Config $config + * + * @return $this + */ + public function setUserConfig(Config $config) + { + $config->getConfigObject()->setKeyColumn('name'); + $this->userConfig = $config; + return $this; + } + + /** + * Return the user's navigation configuration + * + * @param string $type + * + * @return Config + */ + public function getUserConfig($type = null) + { + if ($this->userConfig === null || $type !== null) { + if ($type === null) { + throw new ProgrammingError('You need to pass a type if no user configuration is set'); + } + + $this->setUserConfig(Config::navigation($type, $this->getUser()->getUsername())); + } + + return $this->userConfig; + } + + /** + * Set the shared navigation configuration + * + * @param Config $config + * + * @return $this + */ + public function setShareConfig(Config $config) + { + $config->getConfigObject()->setKeyColumn('name'); + $this->shareConfig = $config; + return $this; + } + + /** + * Return the shared navigation configuration + * + * @param string $type + * + * @return Config + */ + public function getShareConfig($type = null) + { + if ($this->shareConfig === null) { + if ($type === null) { + throw new ProgrammingError('You need to pass a type if no share configuration is set'); + } + + $this->setShareConfig(Config::navigation($type)); + } + + return $this->shareConfig; + } + + /** + * Set the available navigation item types + * + * @param array $itemTypes + * + * @return $this + */ + public function setItemTypes(array $itemTypes) + { + $this->itemTypes = $itemTypes; + return $this; + } + + /** + * Return the available navigation item types + * + * @return array + */ + public function getItemTypes() + { + return $this->itemTypes ?: array(); + } + + /** + * Return a list of available parent items for the given type of navigation item + * + * @param string $type + * @param string $owner + * + * @return array + */ + public function listAvailableParents($type, $owner = null) + { + $children = $this->itemToLoad ? $this->getFlattenedChildren($this->itemToLoad) : array(); + + $names = array(); + foreach ($this->getShareConfig($type) as $sectionName => $sectionConfig) { + if ((string) $sectionName !== $this->itemToLoad + && $sectionConfig->owner === ($owner ?: $this->getUser()->getUsername()) + && ! in_array($sectionName, $children, true) + ) { + $names[] = $sectionName; + } + } + + foreach ($this->getUserConfig($type) as $sectionName => $sectionConfig) { + if ((string) $sectionName !== $this->itemToLoad + && ! in_array($sectionName, $children, true) + ) { + $names[] = $sectionName; + } + } + + return $names; + } + + /** + * Recursively return all children of the given navigation item + * + * @param string $name + * + * @return array + */ + protected function getFlattenedChildren($name) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + return array(); + } + + $children = array(); + foreach ($config->toArray() as $sectionName => $sectionConfig) { + if (isset($sectionConfig['parent']) && $sectionConfig['parent'] === $name) { + $children[] = $sectionName; + $children = array_merge($children, $this->getFlattenedChildren($sectionName)); + } + } + + return $children; + } + + /** + * Populate the form with the given navigation item's config + * + * @param string $name + * + * @return $this + * + * @throws NotFoundError In case no navigation item with the given name is found + */ + public function load($name) + { + if ($this->getConfigForItem($name) === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $this->itemToLoad = $name; + return $this; + } + + /** + * Add a new navigation item + * + * The navigation item to add is identified by the array-key `name'. + * + * @param array $data + * + * @return $this + * + * @throws InvalidArgumentException In case $data does not contain a navigation item name or type + * @throws IcingaException In case a navigation item with the same name already exists + */ + public function add(array $data) + { + if (! isset($data['name'])) { + throw new InvalidArgumentException('Key \'name\' missing'); + } elseif (! isset($data['type'])) { + throw new InvalidArgumentException('Key \'type\' missing'); + } + + $shared = false; + $config = $this->getUserConfig($data['type']); + if ((isset($data['users']) && $data['users']) || (isset($data['groups']) && $data['groups'])) { + if ($this->getUser()->can('user/share/navigation')) { + $data['owner'] = $this->getUser()->getUsername(); + $config = $this->getShareConfig($data['type']); + $shared = true; + } else { + unset($data['users']); + unset($data['groups']); + } + } elseif (isset($data['parent']) && $data['parent'] && $this->hasBeenShared($data['parent'], $data['type'])) { + $data['owner'] = $this->getUser()->getUsername(); + $config = $this->getShareConfig($data['type']); + $shared = true; + } + + $itemName = $data['name']; + $exists = $config->hasSection($itemName); + if (! $exists) { + if ($shared) { + $exists = $this->getUserConfig($data['type'])->hasSection($itemName); + } else { + $exists = (bool) $this->getShareConfig($data['type']) + ->select() + ->where('name', $itemName) + ->where('owner', $this->getUser()->getUsername()) + ->count(); + } + } + + if ($exists) { + throw new IcingaException( + $this->translate('A navigation item with the name "%s" does already exist'), + $itemName + ); + } + + unset($data['name']); + $config->setSection($itemName, $data); + $this->setIniConfig($config); + return $this; + } + + /** + * Edit a navigation item + * + * @param string $name + * @param array $data + * + * @return $this + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case a navigation item with the same name already exists + */ + public function edit($name, array $data) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } else { + $itemConfig = $config->getSection($name); + } + + $shared = false; + if ($this->hasBeenShared($name)) { + if (isset($data['parent']) && $data['parent'] + ? ! $this->hasBeenShared($data['parent']) + : ((! isset($data['users']) || ! $data['users']) && (! isset($data['groups']) || ! $data['groups'])) + ) { + // It is shared but shouldn't anymore + $config = $this->unshare($name, isset($data['parent']) ? $data['parent'] : null); + } + } elseif ((isset($data['users']) && $data['users']) || (isset($data['groups']) && $data['groups'])) { + if ($this->getUser()->can('user/share/navigation')) { + // It is not shared yet but should be + $this->secondaryConfig = $config; + $config = $this->getShareConfig(); + $data['owner'] = $this->getUser()->getUsername(); + $shared = true; + } else { + unset($data['users']); + unset($data['groups']); + } + } elseif (isset($data['parent']) && $data['parent'] && $this->hasBeenShared($data['parent'])) { + // Its parent is shared so should it itself + $this->secondaryConfig = $config; + $config = $this->getShareConfig(); + $data['owner'] = $this->getUser()->getUsername(); + $shared = true; + } + + $oldName = null; + if (isset($data['name'])) { + if ($data['name'] !== $name) { + $oldName = $name; + $name = $data['name']; + + $exists = $config->hasSection($name); + if (! $exists) { + $ownerName = $itemConfig->owner ?: $this->getUser()->getUsername(); + if ($shared || $this->hasBeenShared($oldName)) { + if ($ownerName === $this->getUser()->getUsername()) { + $exists = $this->getUserConfig()->hasSection($name); + } else { + $exists = Config::navigation($itemConfig->type, $ownerName)->hasSection($name); + } + } else { + $exists = (bool) $this->getShareConfig() + ->select() + ->where('name', $name) + ->where('owner', $ownerName) + ->count(); + } + } + + if ($exists) { + throw new IcingaException( + $this->translate('A navigation item with the name "%s" does already exist'), + $name + ); + } + } + + unset($data['name']); + } + + $itemConfig->merge($data); + + if ($shared) { + // Share all descendant children + foreach ($this->getFlattenedChildren($oldName ?: $name) as $child) { + $childConfig = $this->secondaryConfig->getSection($child); + $this->secondaryConfig->removeSection($child); + $childConfig->owner = $this->getUser()->getUsername(); + $config->setSection($child, $childConfig); + } + } + + if ($oldName) { + // Update the parent name on all direct children + foreach ($config as $sectionConfig) { + if ($sectionConfig->parent === $oldName) { + $sectionConfig->parent = $name; + } + } + + $config->removeSection($oldName); + } + + if ($this->secondaryConfig !== null) { + $this->secondaryConfig->removeSection($oldName ?: $name); + } + + $config->setSection($name, $itemConfig); + $this->setIniConfig($config); + return $this; + } + + /** + * Remove a navigation item + * + * @param string $name + * + * @return ConfigObject The navigation item's config + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case the navigation item has still children + */ + public function delete($name) + { + $config = $this->getConfigForItem($name); + if ($config === null) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $children = $this->getFlattenedChildren($name); + if (! empty($children)) { + throw new IcingaException( + $this->translate( + 'Unable to delete navigation item "%s". There' + . ' are other items dependent from it: %s' + ), + $name, + join(', ', $children) + ); + } + + $section = $config->getSection($name); + $config->removeSection($name); + $this->setIniConfig($config); + return $section; + } + + /** + * Unshare the given navigation item + * + * @param string $name + * @param string $parent + * + * @return Config The new config of the given navigation item + * + * @throws NotFoundError In case no navigation item with the given name is found + * @throws IcingaException In case the navigation item has a parent assigned to it + */ + public function unshare($name, $parent = null) + { + $config = $this->getShareConfig(); + if (! $config->hasSection($name)) { + throw new NotFoundError('No navigation item called "%s" found', $name); + } + + $itemConfig = $config->getSection($name); + if ($parent === null) { + $parent = $itemConfig->parent; + } + + if ($parent && $this->hasBeenShared($parent)) { + throw new IcingaException( + $this->translate( + 'Unable to unshare navigation item "%s". It is dependent from item "%s".' + . ' Dependent items can only be unshared by unsharing their parent' + ), + $name, + $parent + ); + } + + $children = $this->getFlattenedChildren($name); + $config->removeSection($name); + $this->secondaryConfig = $config; + + if (! $itemConfig->owner || $itemConfig->owner === $this->getUser()->getUsername()) { + $config = $this->getUserConfig(); + } else { + $config = Config::navigation($itemConfig->type, $itemConfig->owner); + } + + foreach ($children as $child) { + $childConfig = $this->secondaryConfig->getSection($child); + unset($childConfig->owner); + $this->secondaryConfig->removeSection($child); + $config->setSection($child, $childConfig); + } + + unset($itemConfig->owner); + unset($itemConfig->users); + unset($itemConfig->groups); + + $config->setSection($name, $itemConfig); + $this->setIniConfig($config); + return $config; + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $shared = false; + $itemTypes = $this->getItemTypes(); + $itemType = isset($formData['type']) ? $formData['type'] : key($itemTypes); + if ($itemType === null) { + throw new ProgrammingError( + 'This should actually not happen. Create a bug report at https://github.com/icinga/icingaweb2' + . ' or remove this assertion if you know what you\'re doing' + ); + } + + $itemForm = $this->getItemForm($itemType); + + $this->addElement( + 'text', + 'name', + array( + 'required' => true, + 'label' => $this->translate('Name'), + 'description' => $this->translate( + 'The name of this navigation item that is used to differentiate it from others' + ) + ) + ); + + if ((! $itemForm->requiresParentSelection() || ! isset($formData['parent']) || ! $formData['parent']) + && $this->getUser()->can('user/share/navigation') + ) { + $checked = isset($formData['shared']) ? null : (isset($formData['users']) || isset($formData['groups'])); + + $this->addElement( + 'checkbox', + 'shared', + array( + 'autosubmit' => true, + 'ignore' => true, + 'value' => $checked, + 'label' => $this->translate('Shared'), + 'description' => $this->translate('Tick this box to share this item with others') + ) + ); + + if ($checked || (isset($formData['shared']) && $formData['shared'])) { + $shared = true; + $this->addElement( + 'textarea', + 'users', + array( + 'label' => $this->translate('Users'), + 'description' => $this->translate( + 'Comma separated list of usernames to share this item with' + ) + ) + ); + $this->addElement( + 'textarea', + 'groups', + array( + 'label' => $this->translate('Groups'), + 'description' => $this->translate( + 'Comma separated list of group names to share this item with' + ) + ) + ); + } + } + + if (empty($itemTypes) || count($itemTypes) === 1) { + $this->addElement( + 'hidden', + 'type', + array( + 'value' => $itemType + ) + ); + } else { + $this->addElement( + 'select', + 'type', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Type'), + 'description' => $this->translate('The type of this navigation item'), + 'multiOptions' => $itemTypes + ) + ); + } + + if (! $shared && $itemForm->requiresParentSelection()) { + if ($this->itemToLoad && $this->hasBeenShared($this->itemToLoad)) { + $itemConfig = $this->getShareConfig()->getSection($this->itemToLoad); + $availableParents = $this->listAvailableParents($itemType, $itemConfig->owner); + } else { + $availableParents = $this->listAvailableParents($itemType); + } + + $this->addElement( + 'select', + 'parent', + array( + 'allowEmpty' => true, + 'autosubmit' => true, + 'label' => $this->translate('Parent'), + 'description' => $this->translate( + 'The parent item to assign this navigation item to. ' + . 'Select "None" to make this a main navigation item' + ), + 'multiOptions' => ['' => $this->translate('None', 'No parent for a navigation item')] + + (empty($availableParents) ? [] : array_combine($availableParents, $availableParents)) + ) + ); + } else { + $this->addElement('hidden', 'parent', ['disabled' => true]); + } + + $this->addSubForm($itemForm, 'item_form'); + $itemForm->create($formData); // May require a parent which gets set by addSubForm() + } + + /** + * DO NOT USE! This will be removed soon, very soon... + */ + public function setDefaultUrl($url) + { + $this->defaultUrl = $url; + } + + /** + * Populate the configuration of the navigation item to load + */ + public function onRequest() + { + if ($this->itemToLoad) { + $data = $this->getConfigForItem($this->itemToLoad)->getSection($this->itemToLoad)->toArray(); + $data['name'] = $this->itemToLoad; + $this->populate($data); + } elseif ($this->defaultUrl !== null) { + $this->populate(array('url' => $this->defaultUrl)); + } + } + + /** + * {@inheritdoc} + */ + public function isValid($formData) + { + if (! parent::isValid($formData)) { + return false; + } + + $valid = true; + if (isset($formData['users']) && $formData['users']) { + $parsedUserRestrictions = array(); + foreach (Auth::getInstance()->getRestrictions('application/share/users') as $userRestriction) { + $parsedUserRestrictions[] = array_map('trim', explode(',', $userRestriction)); + } + + if (! empty($parsedUserRestrictions)) { + $desiredUsers = array_map('trim', explode(',', $formData['users'])); + array_unshift($parsedUserRestrictions, $desiredUsers); + $forbiddenUsers = call_user_func_array('array_diff', $parsedUserRestrictions); + if (! empty($forbiddenUsers)) { + $valid = false; + $this->getElement('users')->addError( + sprintf( + $this->translate( + 'You are not permitted to share this navigation item with the following users: %s' + ), + implode(', ', $forbiddenUsers) + ) + ); + } + } + } + + if (isset($formData['groups']) && $formData['groups']) { + $parsedGroupRestrictions = array(); + foreach (Auth::getInstance()->getRestrictions('application/share/groups') as $groupRestriction) { + $parsedGroupRestrictions[] = array_map('trim', explode(',', $groupRestriction)); + } + + if (! empty($parsedGroupRestrictions)) { + $desiredGroups = array_map('trim', explode(',', $formData['groups'])); + array_unshift($parsedGroupRestrictions, $desiredGroups); + $forbiddenGroups = call_user_func_array('array_diff', $parsedGroupRestrictions); + if (! empty($forbiddenGroups)) { + $valid = false; + $this->getElement('groups')->addError( + sprintf( + $this->translate( + 'You are not permitted to share this navigation item with the following groups: %s' + ), + implode(', ', $forbiddenGroups) + ) + ); + } + } + } + + return $valid; + } + + /** + * {@inheritdoc} + */ + protected function writeConfig(Config $config) + { + parent::writeConfig($config); + + if ($this->secondaryConfig !== null) { + $this->config = $this->secondaryConfig; // Causes the config being displayed to the user in case of an error + parent::writeConfig($this->secondaryConfig); + } + } + + /** + * Return the navigation configuration the given item is a part of + * + * @param string $name + * + * @return Config|null In case the item is not part of any configuration + */ + protected function getConfigForItem($name) + { + if ($this->getUserConfig()->hasSection($name)) { + return $this->getUserConfig(); + } elseif ($this->getShareConfig()->hasSection($name)) { + if ($this->getShareConfig()->get($name, 'owner') === $this->getUser()->getUsername() + || $this->getUser()->can('user/share/navigation') + ) { + return $this->getShareConfig(); + } + } + } + + /** + * Return whether the given navigation item has been shared + * + * @param string $name + * @param string $type + * + * @return bool + */ + protected function hasBeenShared($name, $type = null) + { + return $this->getShareConfig($type) === $this->getConfigForItem($name); + } + + /** + * Return the form for the given type of navigation item + * + * @param string $type + * + * @return Form + */ + protected function getItemForm($type) + { + $className = StringHelper::cname($type, '-') . 'Form'; + + $form = null; + $classPath = null; + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + $classPath = 'Icinga\\Module\\' + . ucfirst($module->getName()) + . '\\' + . static::FORM_NS + . '\\' + . $className; + if (class_exists($classPath)) { + $form = new $classPath(); + break; + } + } + + if ($form === null) { + $classPath = 'Icinga\\' . static::FORM_NS . '\\' . $className; + if (class_exists($classPath)) { + $form = new $classPath(); + } + } + + if ($form === null) { + Logger::debug( + 'Failed to find custom navigation item form %s for item %s. Using form NavigationItemForm now', + $className, + $type + ); + + $form = new NavigationItemForm(); + } elseif (! $form instanceof NavigationItemForm) { + throw new ProgrammingError('Class %s must inherit from NavigationItemForm', $classPath); + } + + return $form; + } +} diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php new file mode 100644 index 0000000..6cf15e7 --- /dev/null +++ b/application/forms/Navigation/NavigationItemForm.php @@ -0,0 +1,114 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Navigation; + +use Icinga\Web\Form; +use Icinga\Web\Url; + +class NavigationItemForm extends Form +{ + /** + * Whether to create a select input to choose a parent for a navigation item of a particular type + * + * @var bool + */ + protected $requiresParentSelection = false; + + /** + * Return whether to create a select input to choose a parent for a navigation item of a particular type + * + * @return bool + */ + public function requiresParentSelection() + { + return $this->requiresParentSelection; + } + + /** + * {@inheritdoc} + */ + public function createElements(array $formData) + { + $this->addElement( + 'select', + 'target', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Target'), + 'description' => $this->translate('The target where to open this navigation item\'s url'), + 'multiOptions' => array( + '_blank' => $this->translate('New Window'), + '_next' => $this->translate('New Column'), + '_main' => $this->translate('Single Column'), + '_self' => $this->translate('Current Column') + ) + ) + ); + + $this->addElement( + 'textarea', + 'url', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Url'), + 'description' => $this->translate( + 'The url of this navigation item. Leave blank if only the name should be displayed.' + . ' For urls with username and password and for all external urls,' + . ' make sure to prepend an appropriate protocol identifier (e.g. http://example.tld)' + ), + 'validators' => array( + array( + 'Callback', + false, + array( + 'callback' => function ($url) { + // Matches if the given url contains obviously + // a username but not any protocol identifier + return !preg_match('#^((?=[^/@]).)+@.*$#', $url); + }, + 'messages' => array( + 'callbackValue' => $this->translate( + 'Missing protocol identifier' + ) + ) + ) + ) + ) + ) + ); + + $this->addElement( + 'text', + 'icon', + array( + 'allowEmpty' => true, + 'label' => $this->translate('Icon'), + 'description' => $this->translate( + 'The icon of this navigation item. Leave blank if you do not want a icon being displayed' + ) + ) + ); + } + + /** + * {@inheritdoc} + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + // The regex here specifically matches the port-macro as it's the only one preventing Url::fromPath() from + // successfully parsing the given url. Any other macro such as for the scheme or host simply gets identified + // as path which is just fine in this case. + if (isset($values['url']) && $values['url'] && !preg_match('~://.+:\d*?(\$.+\$)~', $values['url'])) { + $url = Url::fromPath($values['url']); + if ($url->getBasePath() === $this->getRequest()->getBasePath()) { + $values['url'] = $url->getRelativeUrl(); + } else { + $values['url'] = $url->getAbsoluteUrl(); + } + } + + return $values; + } +} diff --git a/application/forms/PreferenceForm.php b/application/forms/PreferenceForm.php new file mode 100644 index 0000000..3e431db --- /dev/null +++ b/application/forms/PreferenceForm.php @@ -0,0 +1,485 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use DateTimeZone; +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\User\Preferences; +use Icinga\User\Preferences\PreferencesStore; +use Icinga\Util\TimezoneDetect; +use Icinga\Web\Form; +use Icinga\Web\Notification; +use Icinga\Web\Session; +use Icinga\Web\StyleSheet; +use ipl\Html\HtmlElement; +use ipl\I18n\GettextTranslator; +use ipl\I18n\Locale; +use ipl\I18n\StaticTranslator; + +/** + * Form class to adjust user preferences + */ +class PreferenceForm extends Form +{ + /** + * The preferences to work with + * + * @var Preferences + */ + protected $preferences; + + /** + * The preference store to use + * + * @var PreferencesStore + */ + protected $store; + + /** + * Initialize this form + */ + public function init() + { + $this->setName('form_config_preferences'); + $this->setSubmitLabel($this->translate('Save to the Preferences')); + } + + /** + * Set preferences to work with + * + * @param Preferences $preferences The preferences to work with + * + * @return $this + */ + public function setPreferences(Preferences $preferences) + { + $this->preferences = $preferences; + return $this; + } + + /** + * Set the preference store to use + * + * @param PreferencesStore $store The preference store to use + * + * @return $this + */ + public function setStore(PreferencesStore $store) + { + $this->store = $store; + return $this; + } + + /** + * Persist preferences + * + * @return $this + */ + public function save() + { + $this->store->save($this->preferences); + return $this; + } + + /** + * Adjust preferences and persist them + * + * @see Form::onSuccess() + */ + public function onSuccess() + { + $currentPreferences = $this->Auth()->getUser()->getPreferences(); + $oldTheme = $currentPreferences->getValue('icingaweb', 'theme'); + $oldMode = $currentPreferences->getValue('icingaweb', 'theme_mode'); + $oldLocale = $currentPreferences->getValue('icingaweb', 'language'); + $defaultTheme = Config::app()->get('themes', 'default', StyleSheet::DEFAULT_THEME); + + $this->preferences = new Preferences($this->store ? $this->store->load() : array()); + $webPreferences = $this->preferences->get('icingaweb'); + foreach ($this->getValues() as $key => $value) { + if ($value === '' + || $value === null + || $value === 'autodetect' + || ($key === 'theme' && $value === $defaultTheme) + ) { + if (isset($webPreferences[$key])) { + unset($webPreferences[$key]); + } + } else { + $webPreferences[$key] = $value; + } + } + $this->preferences->icingaweb = $webPreferences; + Session::getSession()->user->setPreferences($this->preferences); + + if ((($theme = $this->getElement('theme')) !== null + && ($theme = $theme->getValue()) !== $oldTheme + && ($theme !== $defaultTheme || $oldTheme !== null)) + || (($mode = $this->getElement('theme_mode')) !== null + && ($mode->getValue()) !== $oldMode) + ) { + $this->getResponse()->setReloadCss(true); + } + + if (($locale = $this->getElement('language')) !== null + && $locale->getValue() !== 'autodetect' + && $locale->getValue() !== $oldLocale + ) { + $this->getResponse()->setHeader('X-Icinga-Redirect-Http', 'yes'); + } + + try { + if ($this->store && $this->getElement('btn_submit')->isChecked()) { + $this->save(); + Notification::success($this->translate('Preferences successfully saved')); + } else { + Notification::success($this->translate('Preferences successfully saved for the current session')); + } + } catch (Exception $e) { + Logger::error($e); + Notification::error($e->getMessage()); + } + } + + /** + * Populate preferences + * + * @see Form::onRequest() + */ + public function onRequest() + { + $auth = Auth::getInstance(); + $values = $auth->getUser()->getPreferences()->get('icingaweb'); + + if (! isset($values['language'])) { + $values['language'] = 'autodetect'; + } + + if (! isset($values['timezone'])) { + $values['timezone'] = 'autodetect'; + } + + if (! isset($values['auto_refresh'])) { + $values['auto_refresh'] = '1'; + } + + $this->populate($values); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + if (setlocale(LC_ALL, 0) === 'C') { + $this->warning( + $this->translate( + 'Your language setting is not applied because your platform is missing the corresponding locale.' + . ' Make sure to install the correct language pack and restart your web server afterwards.' + ), + false + ); + } + + $themeFile = StyleSheet::getThemeFile(Config::app()->get('themes', 'default')); + if (! (bool) Config::app()->get('themes', 'disabled', false)) { + $themes = Icinga::app()->getThemes(); + if (count($themes) > 1) { + $defaultTheme = Config::app()->get('themes', 'default', StyleSheet::DEFAULT_THEME); + if (isset($themes[$defaultTheme])) { + $themes[$defaultTheme] .= ' (' . $this->translate('default') . ')'; + } + $this->addElement( + 'select', + 'theme', + array( + 'label' => $this->translate('Theme', 'Form element label'), + 'multiOptions' => $themes, + 'autosubmit' => true, + 'value' => $this->preferences->getValue( + 'icingaweb', + 'theme', + $defaultTheme + ) + ) + ); + } + } + + if (isset($formData['theme'])) { + $themeFile = StyleSheet::getThemeFile($formData['theme']); + } + + $disabled = []; + if ($themeFile !== null) { + $file = @file_get_contents($themeFile); + if ($file && strpos($file, StyleSheet::LIGHT_MODE_IDENTIFIER) === false) { + $disabled = ['', 'light', 'system']; + } + } + + $this->addElement( + 'radio', + 'theme_mode', + [ + 'class' => 'theme-mode-input', + 'label' => $this->translate('Theme Mode'), + 'multiOptions' => [ + '' => HtmlElement::create( + 'img', + ['src' => $this->getView()->href('img/theme-mode-thumbnail-dark.svg')] + ) . HtmlElement::create('span', [], $this->translate('Dark')), + 'light' => HtmlElement::create( + 'img', + ['src' => $this->getView()->href('img/theme-mode-thumbnail-light.svg')] + ) . HtmlElement::create('span', [], $this->translate('Light')), + 'system' => HtmlElement::create( + 'img', + ['src' => $this->getView()->href('img/theme-mode-thumbnail-system.svg')] + ) . HtmlElement::create('span', [], $this->translate('System')) + ], + 'disable' => $disabled, + 'escape' => false, + 'decorators' => array_merge( + array_slice(self::$defaultElementDecorators, 0, -1), + [['HtmlTag', ['tag' => 'div', 'class' => 'control-group theme-mode']]] + ) + ] + ); + + /** @var GettextTranslator $translator */ + $translator = StaticTranslator::$instance; + + $languages = array(); + $availableLocales = $translator->listLocales(); + + $locale = $this->getLocale($availableLocales); + if ($locale !== null) { + $languages['autodetect'] = sprintf($this->translate('Browser (%s)', 'preferences.form'), $locale); + } + + $availableLocales[] = $translator->getDefaultLocale(); + foreach ($availableLocales as $language) { + $languages[$language] = $language; + } + + $tzList = array(); + $tzList['autodetect'] = sprintf( + $this->translate('Browser (%s)', 'preferences.form'), + $this->getDefaultTimezone() + ); + foreach (DateTimeZone::listIdentifiers() as $tz) { + $tzList[$tz] = $tz; + } + + $this->addElement( + 'select', + 'language', + array( + 'required' => true, + 'label' => $this->translate('Your Current Language'), + 'description' => $this->translate('Use the following language to display texts and messages'), + 'multiOptions' => $languages, + 'value' => substr(setlocale(LC_ALL, 0), 0, 5) + ) + ); + + $this->addElement( + 'select', + 'timezone', + array( + 'required' => true, + 'label' => $this->translate('Your Current Timezone'), + 'description' => $this->translate('Use the following timezone for dates and times'), + 'multiOptions' => $tzList, + 'value' => $this->getDefaultTimezone() + ) + ); + + $this->addElement( + 'select', + 'show_application_state_messages', + array( + 'required' => true, + 'label' => $this->translate('Show application state messages'), + 'description' => $this->translate('Whether to show application state messages.'), + 'multiOptions' => [ + 'system' => (bool) Config::app()->get('global', 'show_application_state_messages', true) + ? $this->translate('System (Yes)') + : $this->translate('System (No)'), + 1 => $this->translate('Yes'), + 0 => $this->translate('No')], + 'value' => 'system' + ) + ); + + if (Auth::getInstance()->hasPermission('user/application/stacktraces')) { + $this->addElement( + 'checkbox', + 'show_stacktraces', + array( + 'value' => $this->getDefaultShowStacktraces(), + 'label' => $this->translate('Show Stacktraces'), + 'description' => $this->translate('Set whether to show an exception\'s stacktrace.') + ) + ); + } + + $this->addElement( + 'checkbox', + 'show_benchmark', + array( + 'label' => $this->translate('Use benchmark') + ) + ); + + $this->addElement( + 'checkbox', + 'auto_refresh', + array( + 'required' => false, + 'autosubmit' => true, + 'label' => $this->translate('Enable auto refresh'), + 'description' => $this->translate( + 'This option allows you to enable or to disable the global page content auto refresh' + ), + 'value' => 1 + ) + ); + + if (isset($formData['auto_refresh']) && $formData['auto_refresh']) { + $this->addElement( + 'select', + 'auto_refresh_speed', + [ + 'required' => false, + 'label' => $this->translate('Auto refresh speed'), + 'description' => $this->translate( + 'This option allows you to speed up or to slow down the global page content auto refresh' + ), + 'multiOptions' => [ + '0.5' => $this->translate('Fast', 'refresh_speed'), + '' => $this->translate('Default', 'refresh_speed'), + '2' => $this->translate('Moderate', 'refresh_speed'), + '4' => $this->translate('Slow', 'refresh_speed') + ], + 'value' => '' + ] + ); + } + + $this->addElement( + 'number', + 'default_page_size', + array( + 'label' => $this->translate('Default page size'), + 'description' => $this->translate('Default number of items per page for list views'), + 'placeholder' => 25, + 'min' => 25, + 'step' => 1 + ) + ); + + if ($this->store) { + $this->addElement( + 'submit', + 'btn_submit', + array( + 'ignore' => true, + 'label' => $this->translate('Save to the Preferences'), + 'decorators' => array('ViewHelper'), + 'class' => 'btn-primary' + ) + ); + } + + $this->addElement( + 'submit', + 'btn_submit_session', + array( + 'ignore' => true, + 'label' => $this->translate('Save for the current Session'), + 'decorators' => array('ViewHelper') + ) + ); + + $this->setAttrib('data-progress-element', 'preferences-progress'); + $this->addElement( + 'note', + 'preferences-progress', + array( + 'decorators' => array( + 'ViewHelper', + array('Spinner', array('id' => 'preferences-progress')) + ) + ) + ); + + $this->addDisplayGroup( + array('btn_submit', 'btn_submit_session', 'preferences-progress'), + 'submit_buttons', + array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls')) + ) + ) + ); + } + + public function addSubmitButton() + { + return $this; + } + + public function isSubmitted() + { + if (parent::isSubmitted()) { + return true; + } + + return $this->getElement('btn_submit_session')->isChecked(); + } + + /** + * Return the current default timezone + * + * @return string + */ + protected function getDefaultTimezone() + { + $detect = new TimezoneDetect(); + if ($detect->success()) { + return $detect->getTimezoneName(); + } else { + return @date_default_timezone_get(); + } + } + + /** + * Return the preferred locale based on the given HTTP header and the available translations + * + * @return string|null + */ + protected function getLocale($availableLocales) + { + return isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) + ? (new Locale())->getPreferred($_SERVER['HTTP_ACCEPT_LANGUAGE'], $availableLocales) + : null; + } + + /** + * Return the default global setting for show_stacktraces + * + * @return bool + */ + protected function getDefaultShowStacktraces() + { + return Config::app()->get('global', 'show_stacktraces', true); + } +} diff --git a/application/forms/RepositoryForm.php b/application/forms/RepositoryForm.php new file mode 100644 index 0000000..8e4665d --- /dev/null +++ b/application/forms/RepositoryForm.php @@ -0,0 +1,453 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Exception\NotFoundError; +use Icinga\Repository\Repository; +use Icinga\Web\Form; +use Icinga\Web\Notification; + +/** + * Form base-class providing standard functionality for extensible, updatable and reducible repositories + */ +abstract class RepositoryForm extends Form +{ + /** + * Insert mode + */ + const MODE_INSERT = 0; + + /** + * Update mode + */ + const MODE_UPDATE = 1; + + /** + * Delete mode + */ + const MODE_DELETE = 2; + + /** + * The repository being worked with + * + * @var Repository + */ + protected $repository; + + /** + * The target being worked with + * + * @var mixed + */ + protected $baseTable; + + /** + * How to interact with the repository + * + * @var int + */ + protected $mode; + + /** + * The name of the entry being handled when in mode update or delete + * + * @var string + */ + protected $identifier; + + /** + * The data of the entry to pre-populate the form with when in mode insert or update + * + * @var array + */ + protected $data; + + /** + * Set the repository to work with + * + * @param Repository $repository + * + * @return $this + */ + public function setRepository(Repository $repository) + { + $this->repository = $repository; + return $this; + } + + /** + * Return the target being worked with + * + * @return mixed + */ + protected function getBaseTable() + { + if ($this->baseTable === null) { + return $this->repository->getBaseTable(); + } + + return $this->baseTable; + } + + /** + * 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 = null) + { + $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 = null) + { + $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; + } + + /** + * Fetch and return the entry to pre-populate the form with when in mode update + * + * @return false|object + */ + protected function fetchEntry() + { + return $this->repository + ->select() + ->from($this->getBaseTable()) + ->applyFilter($this->createFilter()) + ->fetchRow(); + } + + /** + * Return whether the entry supposed to be removed exists + * + * @return bool + */ + protected function entryExists() + { + $count = $this->repository + ->select() + ->from($this->getBaseTable()) + ->addFilter($this->createFilter()) + ->count(); + return $count > 0; + } + + /** + * Insert the new entry + */ + protected function insertEntry() + { + $this->repository->insert($this->getBaseTable(), $this->getValues()); + } + + /** + * Update the entry + */ + protected function updateEntry() + { + $this->repository->update($this->getBaseTable(), $this->getValues(), $this->createFilter()); + } + + /** + * Delete the entry + */ + protected function deleteEntry() + { + $this->repository->delete($this->getBaseTable(), $this->createFilter()); + } + + /** + * 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->setDefaults($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 ($data === null) { + $row = $this->fetchEntry(); + if ($row === false) { + throw new NotFoundError('Entry "%s" not found', $this->getIdentifier()); + } + + $data = get_object_vars($row); + } + + $this->setDefaults($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->entryExists()) { + 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->insertEntry(); + } 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->updateEntry(); + } 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->deleteEntry(); + } 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 new file mode 100644 index 0000000..58387f7 --- /dev/null +++ b/application/forms/Security/RoleForm.php @@ -0,0 +1,632 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Forms\Security; + +use Icinga\Application\Hook\ConfigFormEventsHook; +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Manager; +use Icinga\Authentication\AdmissionLoader; +use Icinga\Data\Filter\Filter; +use Icinga\Forms\ConfigForm; +use Icinga\Forms\RepositoryForm; +use Icinga\Util\StringHelper; +use Icinga\Web\Notification; +use ipl\Web\Widget\Icon; + +/** + * Form for managing roles + */ +class RoleForm extends RepositoryForm +{ + /** + * The name to use instead of `*` + */ + const WILDCARD_NAME = 'allAndEverything'; + + /** + * The prefix used to deny a permission + */ + const DENY_PREFIX = 'no-'; + + /** + * Provided permissions by currently installed modules + * + * @var array + */ + protected $providedPermissions; + + /** + * Provided restrictions by currently installed modules + * + * @var array + */ + protected $providedRestrictions; + + public function init() + { + $this->setAttrib('class', self::DEFAULT_CLASSES . ' role-form'); + + list($this->providedPermissions, $this->providedRestrictions) = static::collectProvidedPrivileges(); + } + + protected function createFilter() + { + return Filter::where('name', $this->getIdentifier()); + } + + public function filterName($value, $allowBrackets = false) + { + return parent::filterName($value, $allowBrackets) . '_element'; + } + + public function createInsertElements(array $formData = array()) + { + $this->addElement( + 'text', + 'name', + [ + 'required' => true, + 'label' => $this->translate('Role Name'), + 'description' => $this->translate('The name of the role') + ] + ); + $this->addElement( + 'select', + 'parent', + [ + 'label' => $this->translate('Inherit From'), + 'description' => $this->translate('Choose a role from which to inherit privileges'), + 'value' => '', + 'multiOptions' => array_merge( + ['' => $this->translate('None', 'parent role')], + $this->collectRoles() + ) + ] + ); + $this->addElement( + 'textarea', + 'users', + [ + 'label' => $this->translate('Users'), + 'description' => $this->translate('Comma-separated list of users that are assigned to the role') + ] + ); + $this->addElement( + 'textarea', + 'groups', + [ + 'label' => $this->translate('Groups'), + 'description' => $this->translate('Comma-separated list of groups that are assigned to the role') + ] + ); + $this->addElement( + 'checkbox', + self::WILDCARD_NAME, + [ + 'autosubmit' => true, + 'label' => $this->translate('Administrative Access'), + 'description' => $this->translate('Everything is allowed') + ] + ); + $this->addElement( + 'checkbox', + 'unrestricted', + [ + 'autosubmit' => true, + 'uncheckedValue' => null, + 'label' => $this->translate('Unrestricted Access'), + 'description' => $this->translate('Access to any data is completely unrestricted') + ] + ); + + $hasAdminPerm = isset($formData[self::WILDCARD_NAME]) && $formData[self::WILDCARD_NAME]; + $isUnrestricted = isset($formData['unrestricted']) && $formData['unrestricted']; + foreach ($this->providedPermissions as $moduleName => $permissionList) { + $this->sortPermissions($permissionList); + + $anythingGranted = false; + $anythingRefused = false; + $anythingRestricted = false; + + $elements = [$moduleName . '_header']; + // The actual element is added last + + $elements[] = 'permission_header'; + $this->addElement('note', 'permission_header', [ + 'decorators' => [['Callback', ['callback' => function () { + return '<h4>' . $this->translate('Permissions') . '</h4>' + . $this->getView()->icon('ok', $this->translate( + 'Grant access by toggling a switch below' + )) + . $this->getView()->icon('cancel', $this->translate( + 'Deny access by toggling a switch below' + )); + }]], ['HtmlTag', ['tag' => 'div']]] + ]); + + $hasFullPerm = false; + foreach ($permissionList as $name => $spec) { + $elementName = $this->filterName($name); + + if (isset($formData[$elementName]) && $formData[$elementName]) { + $anythingGranted = true; + } + + if ($hasFullPerm || $hasAdminPerm) { + $elementName .= '_fake'; + } + + $denyCheckbox = null; + if (! isset($spec['isFullPerm']) + && substr($name, 0, strlen(self::DENY_PREFIX)) !== self::DENY_PREFIX + ) { + $denyCheckbox = $this->createElement('checkbox', $this->filterName(self::DENY_PREFIX . $name), [ + 'decorators' => ['ViewHelper'] + ]); + $this->addElement($denyCheckbox); + $this->removeFromIteration($denyCheckbox->getName()); + + if (isset($formData[$denyCheckbox->getName()]) && $formData[$denyCheckbox->getName()]) { + $anythingRefused = true; + } + } + + $elements[] = $elementName; + $this->addElement( + 'checkbox', + $elementName, + [ + 'ignore' => $hasFullPerm || $hasAdminPerm, + 'autosubmit' => isset($spec['isFullPerm']), + 'disabled' => $hasFullPerm || $hasAdminPerm ?: null, + 'value' => $hasFullPerm || $hasAdminPerm, + 'label' => isset($spec['label']) + ? $spec['label'] + : join('', iterator_to_array(call_user_func(function ($segments) { + foreach ($segments as $segment) { + if ($segment[0] === '/') { + // Adds a zero-width char after each slash to help browsers break onto newlines + yield '/​'; + yield '<span class="no-wrap">' . substr($segment, 1) . '</span>'; + } else { + yield '<em>' . $segment . '</em>'; + } + } + }, preg_split( + '~(/[^/]+)~', + $name, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )))), + 'description' => isset($spec['description']) ? $spec['description'] : $name, + 'decorators' => array_merge( + array_slice(self::$defaultElementDecorators, 0, 3), + [['Callback', ['callback' => function () use ($denyCheckbox) { + return $denyCheckbox ? $denyCheckbox->render() : ''; + }]]], + array_slice(self::$defaultElementDecorators, 3) + ) + ] + ) + ->getElement($elementName) + ->getDecorator('Label') + ->setOption('escape', false); + + if ($hasFullPerm || $hasAdminPerm) { + // Add a hidden element to preserve the configured permission value + $this->addElement('hidden', $this->filterName($name)); + } + + if (isset($spec['isFullPerm'])) { + $filteredName = $this->filterName($name); + $hasFullPerm = isset($formData[$filteredName]) && $formData[$filteredName]; + } + } + + if (isset($this->providedRestrictions[$moduleName])) { + $elements[] = 'restriction_header'; + $this->addElement('note', 'restriction_header', [ + 'value' => '<h4>' . $this->translate('Restrictions') . '</h4>', + 'decorators' => ['ViewHelper'] + ]); + + foreach ($this->providedRestrictions[$moduleName] as $name => $spec) { + $elementName = $this->filterName($name); + + if (isset($formData[$elementName]) && $formData[$elementName]) { + $anythingRestricted = true; + } + + $elements[] = $elementName; + $this->addElement( + 'text', + $elementName, + [ + 'label' => isset($spec['label']) + ? $spec['label'] + : join('', iterator_to_array(call_user_func(function ($segments) { + foreach ($segments as $segment) { + if ($segment[0] === '/') { + // Add zero-width char after each slash to help browsers break onto newlines + yield '/​'; + yield '<span class="no-wrap">' . substr($segment, 1) . '</span>'; + } else { + yield '<em>' . $segment . '</em>'; + } + } + }, preg_split( + '~(/[^/]+)~', + $name, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )))), + 'description' => $spec['description'], + 'class' => $isUnrestricted ? 'unrestricted-role' : '', + 'readonly' => $isUnrestricted ?: null + ] + ) + ->getElement($elementName) + ->getDecorator('Label') + ->setOption('escape', false); + } + } + + $this->addElement( + 'note', + $moduleName . '_header', + [ + 'decorators' => ['ViewHelper'], + 'value' => '<summary class="collapsible-control">' + . '<span>' . ($moduleName !== 'application' + ? sprintf('%s <em>%s</em>', $moduleName, $this->translate('Module')) + : 'Icinga Web 2' + ) . '</span>' + . '<span class="privilege-preview">' + . ($hasAdminPerm || $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : '') + . ($anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : '') + . (! $isUnrestricted && $anythingRestricted + ? new Icon('filter', ['class' => 'restricted']) + : '' + ) + . '</span>' + . new Icon('angles-down', ['class' => 'collapse-icon']) + . new Icon('angles-left', ['class' => 'expand-icon']) + . '</summary>' + ] + ); + + $this->addDisplayGroup($elements, $moduleName . '_elements', [ + 'decorators' => [ + 'FormElements', + ['HtmlTag', [ + 'tag' => 'details', + 'class' => 'collapsible' + ]], + ['Fieldset'] + ] + ]); + } + } + + protected function createDeleteElements(array $formData) + { + } + + public function fetchEntry() + { + $role = parent::fetchEntry(); + if ($role === false) { + return false; + } + + $values = [ + 'parent' => $role->parent, + 'name' => $role->name, + 'users' => $role->users, + 'groups' => $role->groups, + 'unrestricted' => $role->unrestricted, + self::WILDCARD_NAME => $role->permissions && preg_match('~(?>^|,)\*(?>$|,)~', $role->permissions) + ]; + + if (! empty($role->permissions) || ! empty($role->refusals)) { + $permissions = StringHelper::trimSplit($role->permissions); + $refusals = StringHelper::trimSplit($role->refusals); + + list($permissions, $newRefusals) = AdmissionLoader::migrateLegacyPermissions($permissions); + if (! empty($newRefusals)) { + array_push($refusals, ...$newRefusals); + } + + foreach ($this->providedPermissions as $moduleName => $permissionList) { + $hasFullPerm = false; + foreach ($permissionList as $name => $spec) { + if (in_array($name, $permissions, true)) { + $values[$this->filterName($name)] = 1; + + if (isset($spec['isFullPerm'])) { + $hasFullPerm = true; + } + } + + if (in_array($name, $refusals, true)) { + $values[$this->filterName(self::DENY_PREFIX . $name)] = 1; + } + } + + if ($hasFullPerm) { + unset($values[$this->filterName(Manager::MODULE_PERMISSION_NS . $moduleName)]); + } + } + } + + foreach ($this->providedRestrictions as $moduleName => $restrictionList) { + foreach ($restrictionList as $name => $spec) { + if (isset($role->$name)) { + $values[$this->filterName($name)] = $role->$name; + } + } + } + + return (object) $values; + } + + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues($suppressArrayNotation); + + foreach ($this->providedRestrictions as $moduleName => $restrictionList) { + foreach ($restrictionList as $name => $spec) { + $elementName = $this->filterName($name); + if (isset($values[$elementName])) { + $values[$name] = $values[$elementName]; + unset($values[$elementName]); + } + } + } + + $permissions = []; + if (isset($values[self::WILDCARD_NAME]) && $values[self::WILDCARD_NAME]) { + $permissions[] = '*'; + } + + $refusals = []; + foreach ($this->providedPermissions as $moduleName => $permissionList) { + $hasFullPerm = false; + foreach ($permissionList as $name => $spec) { + $elementName = $this->filterName($name); + if (isset($values[$elementName]) && $values[$elementName]) { + $permissions[] = $name; + + if (isset($spec['isFullPerm'])) { + $hasFullPerm = true; + } + } + + $denyName = $this->filterName(self::DENY_PREFIX . $name); + if (isset($values[$denyName]) && $values[$denyName]) { + $refusals[] = $name; + } + + unset($values[$elementName], $values[$denyName]); + } + + $modulePermission = Manager::MODULE_PERMISSION_NS . $moduleName; + if ($hasFullPerm && ! in_array($modulePermission, $permissions, true)) { + $permissions[] = $modulePermission; + } + } + + unset($values[self::WILDCARD_NAME]); + $values['refusals'] = join(',', $refusals); + $values['permissions'] = join(',', $permissions); + return ConfigForm::transformEmptyValuesToNull($values); + } + + protected function getInsertMessage($success) + { + return $success ? $this->translate('Role created') : $this->translate('Role creation failed'); + } + + protected function getUpdateMessage($success) + { + return $success ? $this->translate('Role updated') : $this->translate('Role update failed'); + } + + protected function getDeleteMessage($success) + { + return $success ? $this->translate('Role removed') : $this->translate('Role removal failed'); + } + + protected function sortPermissions(&$permissions) + { + return uksort($permissions, function ($a, $b) use ($permissions) { + if (isset($permissions[$a]['isUsagePerm'])) { + return isset($permissions[$b]['isFullPerm']) ? 1 : -1; + } elseif (isset($permissions[$b]['isUsagePerm'])) { + return isset($permissions[$a]['isFullPerm']) ? -1 : 1; + } + + $aParts = explode('/', $a); + $bParts = explode('/', $b); + + do { + $a = array_shift($aParts); + $b = array_shift($bParts); + } while ($a === $b); + + return strnatcmp($a ?? '', $b ?? ''); + }); + } + + protected function collectRoles() + { + // Function to get all connected children. Used to avoid reference loops + $getChildren = function ($name, $children = []) use (&$getChildren) { + foreach ($this->repository->select()->where('parent', $name) as $child) { + if (isset($children[$child->name])) { + // Don't follow already established loops here, + // the user should be able to solve such in the UI + continue; + } + + $children[$child->name] = true; + $children = $getChildren($child->name, $children); + } + + return $children; + }; + + $children = $this->getIdentifier() !== null ? $getChildren($this->getIdentifier()) : []; + + $names = []; + foreach ($this->repository->select() as $role) { + if ($role->name !== $this->getIdentifier() && ! isset($children[$role->name])) { + $names[] = $role->name; + } + } + + return array_combine($names, $names); + } + + public function isValid($formData) + { + $valid = parent::isValid($formData); + + if ($valid && ConfigFormEventsHook::runIsValid($this) === false) { + foreach (ConfigFormEventsHook::getLastErrors() as $msg) { + $this->error($msg); + } + + $valid = false; + } + + return $valid; + } + + public function onSuccess() + { + if (parent::onSuccess() === false) { + return false; + } + + if ($this->getIdentifier() && ($newName = $this->getValue('name')) !== $this->getIdentifier()) { + $this->repository->update( + $this->getBaseTable(), + ['parent' => $newName], + Filter::where('parent', $this->getIdentifier()) + ); + } + + if (ConfigFormEventsHook::runOnSuccess($this) === false) { + Notification::error($this->translate( + 'Configuration successfully stored. Though, one or more module hooks failed to run.' + . ' See logs for details' + )); + } + } + + /** + * Collect permissions and restrictions provided by Icinga Web 2 and modules + * + * @return array[$permissions, $restrictions] + */ + public static function collectProvidedPrivileges() + { + $providedPermissions['application'] = [ + 'application/announcements' => [ + 'description' => t('Allow to manage announcements') + ], + 'application/log' => [ + 'description' => t('Allow to view the application log') + ], + 'config/*' => [ + 'description' => t('Allow full config access') + ], + 'config/general' => [ + 'description' => t('Allow to adjust the general configuration') + ], + 'config/modules' => [ + 'description' => t('Allow to enable/disable and configure modules') + ], + 'config/resources' => [ + 'description' => t('Allow to manage resources') + ], + 'config/navigation' => [ + 'description' => t('Allow to view and adjust shared navigation items') + ], + 'config/access-control/*' => [ + 'description' => t('Allow to fully manage access-control') + ], + 'config/access-control/users' => [ + 'description' => t('Allow to manage user accounts') + ], + 'config/access-control/groups' => [ + 'description' => t('Allow to manage user groups') + ], + 'config/access-control/roles' => [ + 'description' => t('Allow to manage roles') + ], + 'user/*' => [ + 'description' => t('Allow all account related functionalities') + ], + 'user/password-change' => [ + 'description' => t('Allow password changes in the account preferences') + ], + 'user/application/stacktraces' => [ + 'description' => t('Allow to adjust in the preferences whether to show stacktraces') + ], + 'user/share/navigation' => [ + 'description' => t('Allow to share navigation items') + ], + 'application/sessions' => [ + 'description' => t('Allow to manage user sessions') + ], + 'application/migrations' => [ + 'description' => t('Allow to apply pending application migrations') + ] + ]; + + $providedRestrictions['application'] = [ + 'application/share/users' => [ + 'description' => t('Restrict which users this role can share items and information with') + ], + 'application/share/groups' => [ + 'description' => t('Restrict which groups this role can share items and information with') + ] + ]; + + $mm = Icinga::app()->getModuleManager(); + foreach ($mm->listInstalledModules() as $moduleName) { + $modulePermission = Manager::MODULE_PERMISSION_NS . $moduleName; + $providedPermissions[$moduleName][$modulePermission] = [ + 'isUsagePerm' => true, + 'label' => t('General Module Access'), + 'description' => sprintf(t('Allow access to module %s'), $moduleName) + ]; + + $module = $mm->getModule($moduleName, false); + $permissions = $module->getProvidedPermissions(); + + $providedPermissions[$moduleName][$moduleName . '/*'] = [ + 'isFullPerm' => true, + 'label' => t('Full Module Access') + ]; + + foreach ($permissions as $permission) { + /** @var object $permission */ + $providedPermissions[$moduleName][$permission->name] = [ + 'description' => $permission->description + ]; + } + + foreach ($module->getProvidedRestrictions() as $restriction) { + $providedRestrictions[$moduleName][$restriction->name] = [ + 'description' => $restriction->description + ]; + } + } + + return [$providedPermissions, $providedRestrictions]; + } +} |