Repository: meltano/meltano

Issue:
Meltano `config list` and `config set` should print settings with `kind:secret` as `(redacted)`
As reported in slack and I have confirmed locally.

Currently `meltano config set` will print the setting value in clear text after setting it, and similarly, `config list` will print the secret value in clear text as well.

Proposed changes:

1. `meltano config set` would print `(redacted)` in place of the setting value when confirming that the value has been stored.
2. `meltano config list` would print `(redacted)` in place of the current value when printing the list of all settings.

cc @tayloramurphy 



Discussion:
Steps to reproduce (from the squared repo):

```console
aj@AJs-MacBook-Pro data % meltano config target-snowflake set password supersecret
2022-10-05T19:37:56.666962Z [info     ] Environment 'userdev' is active
2022-10-05T19:37:56.901772Z [info     ] The default environment (userdev) will be ignored for `meltano config`. To configure a specific Environment, please use option `--environment=<environment name>`.
2022-10-05T19:37:58.098408Z [error    ] Your project's local `discovery.yml` manifest could not be parsed.
2022-10-05T19:37:58.099296Z [warning  ] Falling back on the `discovery.yml` manifest received from https://discovery.meltano.com/discovery.yml...
Loader 'target-snowflake' setting 'password' was set in `.env`: 'supersecret'
aj@AJs-MacBook-Pro data % meltano config target-snowflake list                     
2022-10-05T19:38:28.602383Z [info     ] Environment 'userdev' is active
2022-10-05T19:38:28.876655Z [info     ] The default environment (userdev) will be ignored for `meltano config`. To configure a specific Environment, please use option `--environment=<environment name>`.
2022-10-05T19:38:30.158472Z [error    ] Your project's local `discovery.yml` manifest could not be parsed.
2022-10-05T19:38:30.159501Z [warning  ] Falling back on the `discovery.yml` manifest received from https://discovery.meltano.com/discovery.yml...
account [env: TARGET_SNOWFLAKE_ACCOUNT] current value: 'epa06486' (from `meltano.yml`)
        Account: Snowflake account name (i.e. rtXXXXX.eu-central-1)
dbname [env: TARGET_SNOWFLAKE_DBNAME, TARGET_SNOWFLAKE_DATABASE] current value: None (default)
        DB Name: Snowflake Database name
user [env: TARGET_SNOWFLAKE_USER, TARGET_SNOWFLAKE_USERNAME] current value: None (default)
        User: Snowflake User
password [env: TARGET_SNOWFLAKE_PASSWORD] current value: 'supersecret' (from the TARGET_SNOWFLAKE_PASSWORD variable in `.env`)
        Password: Snowflake Password
```
@kgpayne - Do you mind sizing this for us, and if capacity allows and `weight` is not too large, would be good to pick up in a coming iteration.

cc @tayloramurphy 
@aaronsteers @tayloramurphy I think we should still have some way to verify it's been set correctly. We had a bug in the past where env-var expansion would accidentally remove anything following a `$` from a password, for example, that would be hard to identify without being able to see what Meltano will actually pass on to the plugin. 

A new flag on `config list` perhaps? Or do you think `meltano config` showing the unredacted value will be sufficient (assuming you left that one out in the description on purpose)?
@DouweM - Thanks for the extra context. It's pretty common for secrets to be set in a one-way capacity, without visibility after the value being set. (GitHub does this, for example. Once set, you can override the value but not view it.)

I would personally lean towards also redacting on `meltano config` (with no subcommand) but adding something like `--secrets` `--with_secrets` or `--secrets=true` allowing users to skip redactions.


Related but a tangent, it might be worthwhile to map the `meltano config` base behavior (while prints a json string in the style we'd pass as `config.json`  to a tap or target) into a more explicit subcommand like `debug` or `dump`. 

With either `debug` or `dump` as the subcommand, printing the clear text secrets also becomes more a more predictable UX - with or without any additional CLI args... 🤔💭


@kgpayne can you give this a size estimate on the Product Roadmap project. I don't think we're ready to build yet, but need that estimate. cc @aaronsteers 
This has been marked as stale because it is unassigned, and has not had recent activity. It will be closed after 21 days if no further activity occurs. If this should never go stale, please add the `evergreen` label, or request that it be added.

Probably still relevant

Resolving patch:
diff --git a/src/meltano/cli/config.py b/src/meltano/cli/config.py
--- a/src/meltano/cli/config.py
+++ b/src/meltano/cli/config.py
@@ -235,7 +235,12 @@ def list_settings(ctx, extras: bool):
     # regular and extra settings, since we show custom extras.
     load_extras = True if extras else None
 
-    full_config = settings.config_with_metadata(session=session, extras=load_extras)
+    full_config = settings.config_with_metadata(
+        session=session,
+        extras=load_extras,
+        redacted=True,
+    )
+
     for name, config_metadata in full_config.items():
         value = config_metadata["value"]
         source = config_metadata["source"]
@@ -279,7 +284,13 @@ def list_settings(ctx, extras: bool):
         else:
             label = f"{get_label(config_metadata)}"
 
-        current_value = click.style(f"{value!r}", fg="green")
+        redacted_with_value = value is not None and setting_def.is_redacted
+
+        current_value = click.style(
+            value if redacted_with_value else f"{value!r}",
+            fg="yellow" if redacted_with_value else "green",
+        )
+
         click.echo(f" current value: {current_value}", nl=False)
 
         unexpanded_value = config_metadata.get("unexpanded_value")
diff --git a/src/meltano/cli/interactive/config.py b/src/meltano/cli/interactive/config.py
--- a/src/meltano/cli/interactive/config.py
+++ b/src/meltano/cli/interactive/config.py
@@ -37,7 +37,6 @@
 PLUGIN_COLOR = "magenta"
 ENVIRONMENT_COLOR = "orange1"
 SETTING_COLOR = "blue1"
-VALUE_COLOR = "green"
 
 HOME_SCREEN_TEMPLATE = """[bold underline]Configuring [{{ plugin_color }}]{{ plugin_name.capitalize() | safe }}[/{{ plugin_color }}] {% if environment_name %}in Environment[{{ environment_color }}]{{ environment_name }}[/{{ environment_color }}] {% endif %}Interactively[/bold underline]
 
@@ -179,19 +178,28 @@ def _print_setting(self, name, config_metadata, index, last_index):
             label = f"inherited from '{self.settings.plugin.parent.name}'"
         else:
             label = f"from {source.label}"
-        expanded_value = value if value is not None else "(empty string)"
+
+        def value_is_defined(v=value):
+            return v is not None
+
+        def value_for_display(v=value):
+            return v if value_is_defined(v) else "(empty string)"
+
+        expanded_value = value_for_display()
         unexpanded_value = config_metadata.get("unexpanded_value")
-        if unexpanded_value:
-            current_value = (
-                unexpanded_value if unexpanded_value is not None else "(empty string)"
-            )
 
-            details.add_row(Text("Current Expanded Value"), Text(f"{expanded_value}"))
+        if unexpanded_value:
+            current_value = value_for_display(unexpanded_value)
+            details.add_row(Text("Current expanded value"), Text(f"{expanded_value}"))
         else:
-            current_value = value if value is not None else "(empty string)"
+            current_value = expanded_value
+
+        redacted_with_value = setting_def.is_redacted and value_is_defined()
+        value_color = "yellow" if redacted_with_value else "green"
+
         details.add_row(
-            Text(f"Current Value ({label})"),
-            Text.from_markup(f"[{VALUE_COLOR}]{current_value}[/{VALUE_COLOR}]"),
+            Text(f"Current value ({label})"),
+            Text.from_markup(f"[{value_color}]{current_value}[/{value_color}]"),
         )
 
         if setting_def.kind:
@@ -406,22 +414,26 @@ def set_value(self, setting_name, value, store, interactive=False):
         name = metadata["name"]
         store = metadata["store"]
         is_redacted = metadata["setting"] and metadata["setting"].is_redacted
-        if is_redacted:
-            value = REDACTED_VALUE
+
         click.secho(
             (
                 f"{settings.label.capitalize()} setting '{name}' was set in "
-                f"{store.label}: {value!r}"
+                f"{store.label}: "
             ),
-            fg=VALUE_COLOR,
+            fg="green",
+            nl=False,
+        )
+        click.secho(
+            REDACTED_VALUE if is_redacted else f"{value!r}",
+            fg="yellow" if is_redacted else "green",
         )
 
         current_value, source = settings.get_with_source(name, session=self.session)
         if source != store:
-            if is_redacted:
-                current_value = REDACTED_VALUE
+            current_value = REDACTED_VALUE if is_redacted else f"{value!r}"
+
             click.secho(
-                f"Current value is still: {current_value!r} (from {source.label})",
+                f"Current value is still: {current_value} (from {source.label})",
                 fg="yellow",
             )
 


Test patch:
diff --git a/tests/meltano/cli/test_config.py b/tests/meltano/cli/test_config.py
--- a/tests/meltano/cli/test_config.py
+++ b/tests/meltano/cli/test_config.py
@@ -9,6 +9,7 @@
 from asserts import assert_cli_runner
 from meltano.cli import cli
 from meltano.core.project import Project
+from meltano.core.settings_service import REDACTED_VALUE, SettingValueStore
 
 
 class TestCliConfig:
@@ -94,8 +95,45 @@ def test_config_meltano_test(self, cli_runner):
             == "Testing of the Meltano project configuration is not supported"
         )
 
+    @pytest.mark.usefixtures("project")
+    def test_config_list_redacted(
+        self,
+        cli_runner,
+        tap,
+        session,
+        plugin_settings_service_factory,
+    ):
+        plugin_settings_service = plugin_settings_service_factory(tap)
+        plugin_settings_service.set(
+            "secure",
+            "thisisatest",
+            store=SettingValueStore.DOTENV,
+            session=session,
+        )
+
+        result = cli_runner.invoke(cli, ["config", tap.name, "list"])
+        assert_cli_runner(result)
+
+        assert (
+            f"secure [env: TAP_MOCK_SECURE] current value: {REDACTED_VALUE} (from the "
+            "TAP_MOCK_SECURE variable in `.env`)"
+        ) in result.stdout
+
 
 class TestCliConfigSet:
+    @pytest.mark.usefixtures("project")
+    def test_config_set_redacted(self, cli_runner, tap):
+        result = cli_runner.invoke(
+            cli,
+            ["config", tap.name, "set", "secure", "thisisatest"],
+        )
+        assert_cli_runner(result)
+
+        assert (
+            f"Extractor '{tap.name}' setting 'secure' was set in `.env`: "
+            + REDACTED_VALUE
+        ) in result.stdout
+
     @pytest.mark.usefixtures("tap")
     def test_environments_order_of_precedence(self, project: Project, cli_runner):
         # set base config in `meltano.yml`