Repository: freedomofpress/securedrop

Issue:
Support context-dependent behavior in securedrop-admin sdconfig
## Description 

Currently, the default behavior in `securedrop-admin sdconfig` is that each question will be displayed, even if the question is not relevant for the SecureDrop instance. An enhancement would be to support asking optional questions only when necessary. For example, only an instance that answers "yes" to wanting to configure HTTPS on their source interface needs to answer the three questions added in #3366. This functionality can then replace the approach taken in #3340, which worked around this limitation to implement this behavior for the journalist notifications. 

## User Stories

As a SecureDrop administrator, I want to only answer necessary questions such that it is faster to install SecureDrop. 
Support context-dependent behavior in securedrop-admin sdconfig
## Description 

Currently, the default behavior in `securedrop-admin sdconfig` is that each question will be displayed, even if the question is not relevant for the SecureDrop instance. An enhancement would be to support asking optional questions only when necessary. For example, only an instance that answers "yes" to wanting to configure HTTPS on their source interface needs to answer the three questions added in #3366. This functionality can then replace the approach taken in #3340, which worked around this limitation to implement this behavior for the journalist notifications. 

## User Stories

As a SecureDrop administrator, I want to only answer necessary questions such that it is faster to install SecureDrop. 


Discussion:
I intend to work on this issue, 
following a similar approach to what was taken here:

https://github.com/freedomofpress/securedrop/blob/develop/admin/securedrop_admin/__init__.py#L386


**Edit:** Discussing other approaches with Kushal.

**Edit 2:** I thought it would be nice if `prompt_toolkit` offered some way to display prompts conditionally, but this doesn't seem to be supported. So I'm considering a custom approach to conditional/dependent prompts.
I intend to work on this issue, 
following a similar approach to what was taken here:

https://github.com/freedomofpress/securedrop/blob/develop/admin/securedrop_admin/__init__.py#L386


**Edit:** Discussing other approaches with Kushal.

**Edit 2:** I thought it would be nice if `prompt_toolkit` offered some way to display prompts conditionally, but this doesn't seem to be supported. So I'm considering a custom approach to conditional/dependent prompts.

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -255,115 +255,141 @@ def __init__(self, args):
             ['ssh_users', 'sd', str,
              u'Username for SSH access to the servers',
              SiteConfig.ValidateUser(),
-             None],
+             None,
+             lambda config: True],
             ['daily_reboot_time', 4, int,
              u'Daily reboot time of the server (24-hour clock)',
              SiteConfig.ValidateTime(),
-             int],
+             int,
+             lambda config: True],
             ['app_ip', '10.20.2.2', str,
              u'Local IPv4 address for the Application Server',
              SiteConfig.ValidateIP(),
-             None],
+             None,
+             lambda config: True],
             ['monitor_ip', '10.20.3.2', str,
              u'Local IPv4 address for the Monitor Server',
              SiteConfig.ValidateIP(),
-             None],
+             None,
+             lambda config: True],
             ['app_hostname', 'app', str,
              u'Hostname for Application Server',
              SiteConfig.ValidateNotEmpty(),
-             None],
+             None,
+             lambda config: True],
             ['monitor_hostname', 'mon', str,
              u'Hostname for Monitor Server',
              SiteConfig.ValidateNotEmpty(),
-             None],
+             None,
+             lambda config: True],
             ['dns_server', '8.8.8.8', str,
              u'DNS server specified during installation',
              SiteConfig.ValidateNotEmpty(),
-             None],
+             None,
+             lambda config: True],
             ['securedrop_app_gpg_public_key', 'SecureDrop.asc', str,
              u'Local filepath to public key for '
              'SecureDrop Application GPG public key',
              SiteConfig.ValidatePath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: True],
             ['securedrop_app_https_on_source_interface', False, bool,
              u'Whether HTTPS should be enabled on '
              'Source Interface (requires EV cert)',
              SiteConfig.ValidateYesNo(),
-             lambda x: x.lower() == 'yes'],
+             lambda x: x.lower() == 'yes',
+             lambda config: True],
             ['securedrop_app_https_certificate_cert_src', '', str,
-             u'Local filepath to HTTPS certificate '
-             '(optional, only if using HTTPS on source interface)',
+             u'Local filepath to HTTPS certificate',
              SiteConfig.ValidateOptionalPath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: config.get(
+                'securedrop_app_https_on_source_interface')],
             ['securedrop_app_https_certificate_key_src', '', str,
-             u'Local filepath to HTTPS certificate key '
-             '(optional, only if using HTTPS on source interface)',
+             u'Local filepath to HTTPS certificate key',
              SiteConfig.ValidateOptionalPath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: config.get(
+                'securedrop_app_https_on_source_interface')],
             ['securedrop_app_https_certificate_chain_src', '', str,
-             u'Local filepath to HTTPS certificate chain file '
-             '(optional, only if using HTTPS on source interface)',
+             u'Local filepath to HTTPS certificate chain file',
              SiteConfig.ValidateOptionalPath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: config.get(
+                'securedrop_app_https_on_source_interface')],
             ['securedrop_app_gpg_fingerprint', '', str,
              u'Full fingerprint for the SecureDrop Application GPG Key',
              SiteConfig.ValidateFingerprint(),
-             self.sanitize_fingerprint],
+             self.sanitize_fingerprint,
+             lambda config: True],
             ['ossec_alert_gpg_public_key', 'ossec.pub', str,
              u'Local filepath to OSSEC alerts GPG public key',
              SiteConfig.ValidatePath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: True],
             ['ossec_gpg_fpr', '', str,
              u'Full fingerprint for the OSSEC alerts GPG public key',
              SiteConfig.ValidateFingerprint(),
-             self.sanitize_fingerprint],
+             self.sanitize_fingerprint,
+             lambda config: True],
             ['ossec_alert_email', '', str,
              u'Admin email address for receiving OSSEC alerts',
              SiteConfig.ValidateOSSECEmail(),
-             None],
+             None,
+             lambda config: True],
             ['journalist_alert_gpg_public_key', '', str,
              u'Local filepath to journalist alerts GPG public key (optional)',
              SiteConfig.ValidateOptionalPath(self.args.ansible_path),
-             None],
+             None,
+             lambda config: True],
             ['journalist_gpg_fpr', '', str,
              u'Full fingerprint for the journalist alerts '
              u'GPG public key (optional)',
              SiteConfig.ValidateOptionalFingerprint(),
-             self.sanitize_fingerprint],
+             self.sanitize_fingerprint,
+             lambda config: config.get('journalist_alert_gpg_public_key')],
             ['journalist_alert_email', '', str,
              u'Email address for receiving journalist alerts (optional)',
              SiteConfig.ValidateOptionalEmail(),
-             None],
+             None,
+             lambda config: config.get('journalist_alert_gpg_public_key')],
             ['smtp_relay', "smtp.gmail.com", str,
              u'SMTP relay for sending OSSEC alerts',
              SiteConfig.ValidateNotEmpty(),
-             None],
+             None,
+             lambda config: True],
             ['smtp_relay_port', 587, int,
              u'SMTP port for sending OSSEC alerts',
              SiteConfig.ValidateInt(),
-             int],
+             int,
+             lambda config: True],
             ['sasl_domain', "gmail.com", str,
              u'SASL domain for sending OSSEC alerts',
              None,
-             None],
+             None,
+             lambda config: True],
             ['sasl_username', '', str,
              u'SASL username for sending OSSEC alerts',
              SiteConfig.ValidateOSSECUsername(),
-             None],
+             None,
+             lambda config: True],
             ['sasl_password', '', str,
              u'SASL password for sending OSSEC alerts',
              SiteConfig.ValidateOSSECPassword(),
-             None],
+             None,
+             lambda config: True],
             ['enable_ssh_over_tor', True, bool,
              u'Enable SSH over Tor (recommended, disables SSH over LAN). '
              u'If you respond no, SSH will be available over LAN only',
              SiteConfig.ValidateYesNo(),
-             lambda x: x.lower() == 'yes'],
+             lambda x: x.lower() == 'yes',
+             lambda config: True],
             ['securedrop_supported_locales', [], types.ListType,
              u'Space separated list of additional locales to support '
              '(' + translations + ')',
              SiteConfig.ValidateLocales(self.args.app_path),
-             string.split],
+             string.split,
+             lambda config: True],
         ]
 
     def load_and_update_config(self):
@@ -382,23 +408,17 @@ def update_config(self):
     def user_prompt_config(self):
         config = {}
         for desc in self.desc:
-            (var, default, type, prompt, validator, transform) = desc
-            if var == 'journalist_gpg_fpr':
-                if not config.get('journalist_alert_gpg_public_key',
-                                  None):
-                    config[var] = ''
-                    continue
-            if var == 'journalist_alert_email':
-                if not config.get('journalist_alert_gpg_public_key',
-                                  None):
-                    config[var] = ''
-                    continue
+            (var, default, type, prompt, validator, transform,
+                condition) = desc
+            if not condition(config):
+                config[var] = ''
+                continue
             config[var] = self.user_prompt_config_one(desc,
                                                       self.config.get(var))
         return config
 
     def user_prompt_config_one(self, desc, from_config):
-        (var, default, type, prompt, validator, transform) = desc
+        (var, default, type, prompt, validator, transform, condition) = desc
         if from_config is not None:
             default = from_config
         prompt += ': '


Test patch:
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -573,26 +573,66 @@ def get_desc(self, site_config, var):
                 return desc
 
     def verify_desc_consistency_optional(self, site_config, desc):
-        (var, default, etype, prompt, validator, transform) = desc
+        (var, default, etype, prompt, validator, transform, condition) = desc
         # verify the default passes validation
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
 
     def verify_desc_consistency(self, site_config, desc):
         self.verify_desc_consistency_optional(site_config, desc)
-        (var, default, etype, prompt, validator, transform) = desc
+        (var, default, etype, prompt, validator, transform, condition) = desc
         with pytest.raises(ValidationError):
             site_config.user_prompt_config_one(desc, '')
 
     def verify_prompt_boolean(
             self, site_config, desc):
         self.verify_desc_consistency(site_config, desc)
-        (var, default, etype, prompt, validator, transform) = desc
+        (var, default, etype, prompt, validator, transform, condition) = desc
         assert site_config.user_prompt_config_one(desc, True) is True
         assert site_config.user_prompt_config_one(desc, False) is False
         assert site_config.user_prompt_config_one(desc, 'YES') is True
         assert site_config.user_prompt_config_one(desc, 'NO') is False
 
+    def test_desc_conditional(self):
+        """Ensure that conditional prompts behave correctly.
+
+            Prompts which depend on another question should only be
+            asked if the prior question was answered appropriately."""
+
+        questions = [
+            ['first_question',
+             False,
+             bool,
+             u'Test Question 1',
+             None,
+             lambda x: x.lower() == 'yes',
+             lambda config: True],
+            ['dependent_question',
+             'default_value',
+             str,
+             u'Test Question 2',
+             None,
+             None,
+             lambda config: config.get('first_question', False)]
+        ]
+        args = argparse.Namespace(site_config='tests/files/site-specific',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        site_config.desc = questions
+
+        def auto_prompt(prompt, default, **kwargs):
+            return default
+
+        with mock.patch('prompt_toolkit.prompt', side_effect=auto_prompt):
+            config = site_config.user_prompt_config()
+            assert config['dependent_question'] != 'default_value'
+
+            site_config.desc[0][1] = True
+
+            config = site_config.user_prompt_config()
+            assert config['dependent_question'] == 'default_value'
+
     verify_prompt_ssh_users = verify_desc_consistency
     verify_prompt_app_ip = verify_desc_consistency
     verify_prompt_monitor_ip = verify_desc_consistency
@@ -616,7 +656,7 @@ def verify_prompt_fingerprint_optional(self, site_config, desc):
         assert site_config.user_prompt_config_one(desc, fpr) == clean_fpr
 
     def verify_desc_consistency_allow_empty(self, site_config, desc):
-        (var, default, etype, prompt, validator, transform) = desc
+        (var, default, etype, prompt, validator, transform, condition) = desc
         # verify the default passes validation
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
@@ -647,7 +687,7 @@ def verify_prompt_fingerprint(self, site_config, desc):
     verify_prompt_sasl_password = verify_prompt_not_empty
 
     def verify_prompt_securedrop_supported_locales(self, site_config, desc):
-        (var, default, etype, prompt, validator, transform) = desc
+        (var, default, etype, prompt, validator, transform, condition) = desc
         # verify the default passes validation
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
@@ -672,7 +712,8 @@ def auto_prompt(prompt, default, **kwargs):
 
         with mock.patch('prompt_toolkit.prompt', side_effect=auto_prompt):
             for desc in site_config.desc:
-                (var, default, etype, prompt, validator, transform) = desc
+                (var, default, etype, prompt, validator, transform,
+                    condition) = desc
                 method = 'verify_prompt_' + var
                 print("checking " + method)
                 getattr(self, method)(site_config, desc)