diff --git a/src/applications/config/controller/PhabricatorConfigEditController.php b/src/applications/config/controller/PhabricatorConfigEditController.php index 370ba01f37..0c93c86f09 100644 --- a/src/applications/config/controller/PhabricatorConfigEditController.php +++ b/src/applications/config/controller/PhabricatorConfigEditController.php @@ -1,546 +1,586 @@ getViewer(); $key = $request->getURIData('key'); $options = PhabricatorApplicationConfigOptions::loadAllOptions(); if (empty($options[$key])) { $ancient = PhabricatorExtraConfigSetupCheck::getAncientConfig(); if (isset($ancient[$key])) { $desc = pht( "This configuration has been removed. You can safely delete ". "it.\n\n%s", $ancient[$key]); } else { $desc = pht( 'This configuration option is unknown. It may be misspelled, '. 'or have existed in a previous version of Phabricator.'); } // This may be a dead config entry, which existed in the past but no // longer exists. Allow it to be edited so it can be reviewed and // deleted. $option = id(new PhabricatorConfigOption()) ->setKey($key) ->setType('wild') ->setDefault(null) ->setDescription($desc); $group = null; $group_uri = $this->getApplicationURI(); } else { $option = $options[$key]; $group = $option->getGroup(); $group_uri = $this->getApplicationURI('group/'.$group->getKey().'/'); } $issue = $request->getStr('issue'); if ($issue) { // If the user came here from an open setup issue, send them back. $done_uri = $this->getApplicationURI('issue/'.$issue.'/'); } else { $done_uri = $group_uri; } // Check if the config key is already stored in the database. // Grab the value if it is. $config_entry = id(new PhabricatorConfigEntry()) ->loadOneWhere( 'configKey = %s AND namespace = %s', $key, 'default'); if (!$config_entry) { $config_entry = id(new PhabricatorConfigEntry()) ->setConfigKey($key) ->setNamespace('default') ->setIsDeleted(true); $config_entry->setPHID($config_entry->generatePHID()); } $e_value = null; $errors = array(); if ($request->isFormPost() && !$option->getLocked()) { $result = $this->readRequest( $option, $request); list($e_value, $value_errors, $display_value, $xaction) = $result; $errors = array_merge($errors, $value_errors); if (!$errors) { $editor = id(new PhabricatorConfigEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request); try { $editor->applyTransactions($config_entry, array($xaction)); return id(new AphrontRedirectResponse())->setURI($done_uri); } catch (PhabricatorConfigValidationException $ex) { $e_value = pht('Invalid'); $errors[] = $ex->getMessage(); } } } else { if ($config_entry->getIsDeleted()) { $display_value = null; } else { $display_value = $this->getDisplayValue( $option, $config_entry, $config_entry->getValue()); } } $form = new AphrontFormView(); $error_view = null; if ($errors) { $error_view = id(new PHUIInfoView()) ->setErrors($errors); - } else if ($option->getHidden()) { - $msg = pht( + } + + $status_items = array(); + if ($option->getHidden()) { + $message = pht( 'This configuration is hidden and can not be edited or viewed from '. 'the web interface.'); - $error_view = id(new PHUIInfoView()) - ->setTitle(pht('Configuration Hidden')) - ->setSeverity(PHUIInfoView::SEVERITY_WARNING) - ->appendChild(phutil_tag('p', array(), $msg)); + $status_items[] = id(new PHUIStatusItemView()) + ->setIcon('fa-eye-slash red') + ->setTarget(phutil_tag('strong', array(), pht('Configuration Hidden'))) + ->setNote($message); } else if ($option->getLocked()) { + $message = $option->getLockedMessage(); - $msg = $option->getLockedMessage(); - $error_view = id(new PHUIInfoView()) - ->setTitle(pht('Configuration Locked')) - ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) - ->appendChild(phutil_tag('p', array(), $msg)); + $status_items[] = id(new PHUIStatusItemView()) + ->setIcon('fa-lock red') + ->setTarget(phutil_tag('strong', array(), pht('Configuration Locked'))) + ->setNote($message); + } + + if ($status_items) { + $doc_href = PhabricatorEnv::getDoclink( + 'Configuration Guide: Locked and Hidden Configuration'); + + $doc_link = phutil_tag( + 'a', + array( + 'href' => $doc_href, + 'target' => '_blank', + ), + pht('Configuration Guide: Locked and Hidden Configuration')); + + $status_items[] = id(new PHUIStatusItemView()) + ->setIcon('fa-book') + ->setTarget(phutil_tag('strong', array(), pht('Learn More'))) + ->setNote($doc_link); } if ($option->getHidden() || $option->getLocked()) { $control = null; } else { $control = $this->renderControl( $option, $display_value, $e_value); } $engine = new PhabricatorMarkupEngine(); $engine->setViewer($viewer); $engine->addObject($option, 'description'); $engine->process(); $description = phutil_tag( 'div', array( 'class' => 'phabricator-remarkup', ), $engine->getOutput($option, 'description')); $form ->setUser($viewer) - ->addHiddenInput('issue', $request->getStr('issue')) - ->appendChild( + ->addHiddenInput('issue', $request->getStr('issue')); + + if ($status_items) { + $status_view = id(new PHUIStatusListView()); + + foreach ($status_items as $status_item) { + $status_view->addItem($status_item); + } + + $form->appendControl( id(new AphrontFormMarkupControl()) - ->setLabel(pht('Description')) - ->setValue($description)); + ->setValue($status_view)); + } + + $description = $option->getDescription(); + if (strlen($description)) { + $description_view = new PHUIRemarkupView($viewer, $description); + + $form + ->appendChild( + id(new AphrontFormMarkupControl()) + ->setLabel(pht('Description')) + ->setValue($description_view)); + } if ($group) { $extra = $group->renderContextualDescription( $option, $request); if ($extra !== null) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setValue($extra)); } } $form ->appendChild($control); if (!$option->getLocked()) { $form->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($done_uri) ->setValue(pht('Save Config Entry'))); } if (!$option->getHidden()) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Current Configuration')) ->setValue($this->renderDefaults($option, $config_entry))); } $examples = $this->renderExamples($option); if ($examples) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Examples')) ->setValue($examples)); } $title = pht('Edit %s', $key); $short = pht('Edit'); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setForm($form); if ($error_view) { - $form_box->setInfoView($error_view); + $form_box->setInfoView($error_view); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Config'), $this->getApplicationURI()); if ($group) { $crumbs->addTextCrumb($group->getName(), $group_uri); } $crumbs->addTextCrumb($key, '/config/edit/'.$key); $timeline = $this->buildTransactionTimeline( $config_entry, new PhabricatorConfigTransactionQuery()); $timeline->setShouldTerminate(true); return $this->buildApplicationPage( array( $crumbs, $form_box, $timeline, ), array( 'title' => $title, )); } private function readRequest( PhabricatorConfigOption $option, AphrontRequest $request) { $xaction = new PhabricatorConfigTransaction(); $xaction->setTransactionType(PhabricatorConfigTransaction::TYPE_EDIT); $e_value = null; $errors = array(); $value = $request->getStr('value'); if (!strlen($value)) { $value = null; $xaction->setNewValue( array( 'deleted' => true, 'value' => null, )); return array($e_value, $errors, $value, $xaction); } if ($option->isCustomType()) { $info = $option->getCustomObject()->readRequest($option, $request); list($e_value, $errors, $set_value, $value) = $info; } else { $type = $option->getType(); $set_value = null; switch ($type) { case 'int': if (preg_match('/^-?[0-9]+$/', trim($value))) { $set_value = (int)$value; } else { $e_value = pht('Invalid'); $errors[] = pht('Value must be an integer.'); } break; case 'string': case 'enum': $set_value = (string)$value; break; case 'list': case 'list': $set_value = phutil_split_lines( $request->getStr('value'), $retain_endings = false); foreach ($set_value as $key => $v) { if (!strlen($v)) { unset($set_value[$key]); } } $set_value = array_values($set_value); break; case 'set': $set_value = array_fill_keys($request->getStrList('value'), true); break; case 'bool': switch ($value) { case 'true': $set_value = true; break; case 'false': $set_value = false; break; default: $e_value = pht('Invalid'); $errors[] = pht('Value must be boolean, "true" or "false".'); break; } break; case 'class': if (!class_exists($value)) { $e_value = pht('Invalid'); $errors[] = pht('Class does not exist.'); } else { $base = $option->getBaseClass(); if (!is_subclass_of($value, $base)) { $e_value = pht('Invalid'); $errors[] = pht('Class is not of valid type.'); } else { $set_value = $value; } } break; default: $json = json_decode($value, true); if ($json === null && strtolower($value) != 'null') { $e_value = pht('Invalid'); $errors[] = pht( 'The given value must be valid JSON. This means, among '. 'other things, that you must wrap strings in double-quotes.'); } else { $set_value = $json; } break; } } if (!$errors) { $xaction->setNewValue( array( 'deleted' => false, 'value' => $set_value, )); } else { $xaction = null; } return array($e_value, $errors, $value, $xaction); } private function getDisplayValue( PhabricatorConfigOption $option, PhabricatorConfigEntry $entry, $value) { if ($option->isCustomType()) { return $option->getCustomObject()->getDisplayValue( $option, $entry, $value); } else { $type = $option->getType(); switch ($type) { case 'int': case 'string': case 'enum': case 'class': return $value; case 'bool': return $value ? 'true' : 'false'; case 'list': case 'list': return implode("\n", nonempty($value, array())); case 'set': return implode("\n", nonempty(array_keys($value), array())); default: return PhabricatorConfigJSON::prettyPrintJSON($value); } } } private function renderControl( PhabricatorConfigOption $option, $display_value, $e_value) { if ($option->isCustomType()) { $control = $option->getCustomObject()->renderControl( $option, $display_value, $e_value); } else { $type = $option->getType(); switch ($type) { case 'int': case 'string': $control = id(new AphrontFormTextControl()); break; case 'bool': $control = id(new AphrontFormSelectControl()) ->setOptions( array( '' => pht('(Use Default)'), 'true' => idx($option->getBoolOptions(), 0), 'false' => idx($option->getBoolOptions(), 1), )); break; case 'enum': $options = array_mergev( array( array('' => pht('(Use Default)')), $option->getEnumOptions(), )); $control = id(new AphrontFormSelectControl()) ->setOptions($options); break; case 'class': $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setAncestorClass($option->getBaseClass()) ->setConcreteOnly(true) ->selectSymbolsWithoutLoading(); $names = ipull($symbols, 'name', 'name'); asort($names); $names = array( '' => pht('(Use Default)'), ) + $names; $control = id(new AphrontFormSelectControl()) ->setOptions($names); break; case 'list': case 'list': $control = id(new AphrontFormTextAreaControl()) ->setCaption(pht('Separate values with newlines.')); break; case 'set': $control = id(new AphrontFormTextAreaControl()) ->setCaption(pht('Separate values with newlines or commas.')); break; default: $control = id(new AphrontFormTextAreaControl()) ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_TALL) ->setCustomClass('PhabricatorMonospaced') ->setCaption(pht('Enter value in JSON.')); break; } $control ->setLabel(pht('Database Value')) ->setError($e_value) ->setValue($display_value) ->setName('value'); } return $control; } private function renderExamples(PhabricatorConfigOption $option) { $examples = $option->getExamples(); if (!$examples) { return null; } $table = array(); $table[] = phutil_tag('tr', array('class' => 'column-labels'), array( phutil_tag('th', array(), pht('Example')), phutil_tag('th', array(), pht('Value')), )); foreach ($examples as $example) { list($value, $description) = $example; if ($value === null) { $value = phutil_tag('em', array(), pht('(empty)')); } else { if (is_array($value)) { $value = implode("\n", $value); } } $table[] = phutil_tag('tr', array(), array( phutil_tag('th', array(), $description), phutil_tag('td', array(), $value), )); } require_celerity_resource('config-options-css'); return phutil_tag( 'table', array( 'class' => 'config-option-table', ), $table); } private function renderDefaults( PhabricatorConfigOption $option, PhabricatorConfigEntry $entry) { $stack = PhabricatorEnv::getConfigSourceStack(); $stack = $stack->getStack(); $table = array(); $table[] = phutil_tag('tr', array('class' => 'column-labels'), array( phutil_tag('th', array(), pht('Source')), phutil_tag('th', array(), pht('Value')), )); $is_effective_value = true; foreach ($stack as $key => $source) { $row_classes = array( 'column-labels', ); $value = $source->getKeys( array( $option->getKey(), )); if (!array_key_exists($option->getKey(), $value)) { $value = phutil_tag('em', array(), pht('(No Value Configured)')); } else { $value = $this->getDisplayValue( $option, $entry, $value[$option->getKey()]); if ($is_effective_value) { $is_effective_value = false; $row_classes[] = 'config-options-effective-value'; } } $table[] = phutil_tag( 'tr', array( 'class' => implode(' ', $row_classes), ), array( phutil_tag('th', array(), $source->getName()), phutil_tag('td', array(), $value), )); } require_celerity_resource('config-options-css'); return phutil_tag( 'table', array( 'class' => 'config-option-table', ), $table); } } diff --git a/src/applications/config/management/PhabricatorConfigManagementSetWorkflow.php b/src/applications/config/management/PhabricatorConfigManagementSetWorkflow.php index 97586a77dd..f6d40a5126 100644 --- a/src/applications/config/management/PhabricatorConfigManagementSetWorkflow.php +++ b/src/applications/config/management/PhabricatorConfigManagementSetWorkflow.php @@ -1,172 +1,173 @@ setName('set') ->setExamples('**set** __key__ __value__') ->setSynopsis(pht('Set a local configuration value.')) ->setArguments( array( array( 'name' => 'database', 'help' => pht( 'Update configuration in the database instead of '. 'in local configuration.'), ), array( 'name' => 'args', 'wildcard' => true, ), )); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $argv = $args->getArg('args'); if (count($argv) == 0) { throw new PhutilArgumentUsageException( pht('Specify a configuration key and a value to set it to.')); } $key = $argv[0]; if (count($argv) == 1) { throw new PhutilArgumentUsageException( pht( "Specify a value to set the key '%s' to.", $key)); } $value = $argv[1]; if (count($argv) > 2) { throw new PhutilArgumentUsageException( pht( 'Too many arguments: expected one key and one value.')); } $options = PhabricatorApplicationConfigOptions::loadAllOptions(); if (empty($options[$key])) { throw new PhutilArgumentUsageException( pht( "No such configuration key '%s'! Use `%s` to list all keys.", $key, 'config list')); } $option = $options[$key]; $type = $option->getType(); switch ($type) { case 'string': case 'class': case 'enum': $value = (string)$value; break; case 'int': if (!ctype_digit($value)) { throw new PhutilArgumentUsageException( pht( "Config key '%s' is of type '%s'. Specify an integer.", $key, $type)); } $value = (int)$value; break; case 'bool': if ($value == 'true') { $value = true; } else if ($value == 'false') { $value = false; } else { throw new PhutilArgumentUsageException( pht( "Config key '%s' is of type '%s'. Specify '%s' or '%s'.", $key, $type, 'true', 'false')); } break; default: $value = json_decode($value, true); if (!is_array($value)) { switch ($type) { case 'set': $command = csprintf( './bin/config set %R %s', $key, '{"value1": true, "value2": true}'); $message = sprintf( "%s\n\n %s\n", pht( 'Config key "%s" is of type "%s". Specify it in JSON. '. 'For example:', $key, $type), $command); break; default: if (preg_match('/^listgetArg('database'); if ($option->getLocked() && $use_database) { throw new PhutilArgumentUsageException( pht( - "Config key '%s' is locked and can only be set in local ". - "configuration.", - $key)); + 'Config key "%s" is locked and can only be set in local '. + 'configuration. To learn more, see "%s" in the documentation.', + $key, + pht('Configuration Guide: Locked and Hidden Configuration'))); } try { $option->getGroup()->validateOption($option, $value); } catch (PhabricatorConfigValidationException $validation) { // Convert this into a usage exception so we don't dump a stack trace. throw new PhutilArgumentUsageException($validation->getMessage()); } if ($use_database) { $config_type = 'database'; $config_entry = PhabricatorConfigEntry::loadConfigEntry($key); $config_entry->setValue($value); $config_entry->save(); } else { $config_type = 'local'; id(new PhabricatorConfigLocalSource()) ->setKeys(array($key => $value)); } $console->writeOut( "%s\n", pht("Set '%s' in %s configuration.", $key, $config_type)); } } diff --git a/src/docs/user/configuration/configuration_locked.diviner b/src/docs/user/configuration/configuration_locked.diviner new file mode 100644 index 0000000000..040b838177 --- /dev/null +++ b/src/docs/user/configuration/configuration_locked.diviner @@ -0,0 +1,101 @@ +@title Configuration Guide: Locked and Hidden Configuration +@group config + +Details about locked and hidden configuration. + + +Overview +======== + +Some configuration options are **Locked** or **Hidden**. If an option has one +of these attributes, it means: + + - **Locked Configuration**: This setting can not be written from the web UI. + - **Hidden Configuration**: This setting can not be read or written from + the web UI. + +This document explains these attributes in more detail. + + +Locked Configuration +==================== + +**Locked Configuration** can not be edited from the web UI. In general, you +can edit it from the CLI instead, with `bin/config`: + +``` +phabricator/ $ ./bin/config set +``` + +A few settings have alternate CLI tools. Refer to the setting page for +details. + +Note that these settings can not be written to the database, even from the +CLI. + +Locked values can not be unlocked: they are locked because of what the setting +does or how the setting operates. Some of the reasons configuration options are +locked include: + + +**Required for bootstrapping**: Some options, like `mysql.host`, must be +available before Phabricator can read configuration from the database. + +If you stored `mysql.host` only in the database, Phabricator would not know how +to connect to the database in order to read the value in the first place. + +These options must be provided in a configuration source which is read earlier +in the bootstrapping process, before Phabricator connects to the database. + + +**Errors could not be fixed from the web UI**: Some options, like +`phabricator.base-uri`, can effectively disable the web UI if they are +configured incorrectly. + +If these options could be configured from the web UI, you could not fix them if +you made a mistake (because the web UI would no longer work, so you could not +load the page to change the value). + +We require these options to be edited from the CLI to make sure the editor has +access to fix any mistakes. + + +**Attackers could gain greater access**: Some options could be modified by an +attacker who has gained access to an administrator account in order to gain +greater access. + +For example, an attacker who could modify `metamta.mail-adapter` (and other +similar options), could potentially reconfigure Phabricator to send mail +through an evil server they controlled, then trigger password resets on other +user accounts to compromise them. + +We require these options to be edited from the CLI to make sure the editor +has full access to the install. + + +Hidden Configuration +==================== + +**Hidden Configuration** is similar to locked configuration, but also can not +be //read// from the web UI. + +In almost all cases, configuration is hidden because it is some sort of secret +key or access token for an external service. These values are hidden from the +web UI to prevent administrators (or attackers who have compromised +administrator accounts) from reading them. + +You can review (and edit) hidden configuration from the CLI: + +``` +phabricator/ $ ./bin/config get +phabricator/ $ ./bin/config set + +``` + + +Next Steps +========== + +Continue by: + + - returning to the @{article: Configuration Guide}.