From 3b0e793febaceb85842f0f836bf620a0b950b56d Mon Sep 17 00:00:00 2001 From: James Page Date: Fri, 4 May 2018 12:18:15 +0200 Subject: [PATCH] Use secret_id's with vault-kv relation In order to tighten the security around access to secrets stored in a Vault KV secrets backend, generate a secret_id for each accessing unit, using a response wrapping token which is passed over the relation to the consuming application. The consuming application will then use this token out-of-band of Juju to retrieve the secret_id associated with the AppRole ID directly from Vault. Add a new action 'refresh-secrets' to force a renewal of secret_id's and associated one-shot retrieval tokens across a deployment. A token is only issued when a new approle is created or when a refresh is initiated via the 'refresh-secrets' action. Change-Id: I2cd173514377d65542ea4fa67ccf700ea4b6ab89 --- src/actions.yaml | 2 ++ src/actions/actions.py | 12 +++++++++++ src/actions/refresh-secrets | 1 + src/lib/charm/vault.py | 18 +++++++++++++++- src/reactive/vault_handlers.py | 25 +++++++++++++++++----- unit_tests/test_lib_charm_vault.py | 16 +++++++++++++- unit_tests/test_reactive_vault_handlers.py | 13 ++++++++--- 7 files changed, 77 insertions(+), 10 deletions(-) create mode 120000 src/actions/refresh-secrets diff --git a/src/actions.yaml b/src/actions.yaml index 7b3ff6a..60226fc 100644 --- a/src/actions.yaml +++ b/src/actions.yaml @@ -6,3 +6,5 @@ authorize-charm: description: Token to use to authorize charm required: - token +refresh-secrets: + description: Refresh secret_id's and re-issue retrieval tokens for secrets endpoints diff --git a/src/actions/actions.py b/src/actions/actions.py index 1e465ad..0425536 100755 --- a/src/actions/actions.py +++ b/src/actions/actions.py @@ -29,6 +29,8 @@ import charm.vault as vault import charms.reactive +from charms.reactive.flags import set_flag + def authorize_charm_action(*args): """Create a role allowing the charm to perform certain vault actions. @@ -39,10 +41,20 @@ def authorize_charm_action(*args): role_id = vault.setup_charm_vault_access(action_config['token']) hookenv.leader_set({vault.CHARM_ACCESS_ROLE_ID: role_id}) + +def refresh_secrets(*args): + """Refresh secret_id's and re-issue tokens for secret_id retrieval + on secrets end-points""" + if not hookenv.is_leader(): + hookenv.action_fail('Please run action on lead unit') + set_flag('secrets.refresh') + + # Actions to function mapping, to allow for illegal python action names that # can map to a python function. ACTIONS = { "authorize-charm": authorize_charm_action, + "refresh-secrets": refresh_secrets, } diff --git a/src/actions/refresh-secrets b/src/actions/refresh-secrets new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/refresh-secrets @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/lib/charm/vault.py b/src/lib/charm/vault.py index 9c484ea..e4badb6 100644 --- a/src/lib/charm/vault.py +++ b/src/lib/charm/vault.py @@ -299,6 +299,22 @@ def configure_approle(client, name, cidr, policies): token_ttl='60s', token_max_ttl='60s', policies=policies, - bind_secret_id='false', + bind_secret_id='true', bound_cidr_list=cidr) return client.get_role_id(name) + + +def generate_role_secret_id(client, name, cidr): + """Generate a new secret_id for an AppRole + + :param client: Vault client + :ptype client: hvac.Client + :param name: Name of role + :ptype name: str + :param cidr: Network address of remote unit + :ptype cidr: str + :returns: Vault token to retrieve the response-wrapped response + :rtype: str""" + response = client.write('auth/approle/role/{}/secret-id'.format(name), + wrap_ttl='1h', cidr_list=cidr) + return response['wrap_info']['token'] diff --git a/src/reactive/vault_handlers.py b/src/reactive/vault_handlers.py index 1b74cb2..bb60c01 100644 --- a/src/reactive/vault_handlers.py +++ b/src/reactive/vault_handlers.py @@ -44,6 +44,7 @@ from charms.reactive import ( when, when_file_changed, when_not, + when_any, ) from charms.reactive.relations import ( @@ -370,7 +371,7 @@ def file_change_auto_unlock_mode(): @when('leadership.is_leader') -@when('endpoint.secrets.new-request') +@when_any('endpoint.secrets.new-request', 'secrets.refresh') def configure_secrets_backend(): """ Process requests for setup and access to simple kv secret backends """ @tenacity.retry(wait=tenacity.wait_exponential(multiplier=1, max=10), @@ -400,7 +401,8 @@ def configure_secrets_backend(): return client.auth_approle(charm_role_id) - secrets = endpoint_from_flag('endpoint.secrets.new-request') + secrets = (endpoint_from_flag('endpoint.secrets.new-request') or + endpoint_from_flag('secrets.connected')) requests = secrets.requests() # Configure KV secret backends @@ -411,6 +413,8 @@ def configure_secrets_backend(): continue vault.configure_secret_backend(client, name=backend) + refresh_secrets = is_flag_set('secrets.refresh') + # Configure AppRoles for application unit access for request in requests: # NOTE: backends must start with charm- @@ -437,16 +441,27 @@ def configure_secrets_backend(): hostname=hostname) ) + cidr = '{}/32'.format(access_address) + new_role = (approle_name not in client.list_roles()) + approle_id = vault.configure_approle( client, name=approle_name, - cidr='{}/32'.format(access_address), + cidr=cidr, policies=[policy_name]) - secrets.set_role_id(unit=unit, - role_id=approle_id) + if new_role or refresh_secrets: + wrapped_secret = vault.generate_role_secret_id( + client, + name=approle_name, + cidr=cidr + ) + secrets.set_role_id(unit=unit, + role_id=approle_id, + token=wrapped_secret) clear_flag('endpoint.secrets.new-request') + clear_flag('secrets.refresh') @when('secrets.connected') diff --git a/unit_tests/test_lib_charm_vault.py b/unit_tests/test_lib_charm_vault.py index 825c60f..1a9c6ff 100644 --- a/unit_tests/test_lib_charm_vault.py +++ b/unit_tests/test_lib_charm_vault.py @@ -331,6 +331,20 @@ class TestLibCharmVault(unit_tests.test_utils.CharmTestCase): vault.configure_secret_backend(hvac_client, 'secrets') hvac_client.enable_secret_backend.assert_not_called() + def test_generate_role_secret_id(self): + hvac_client = mock.MagicMock() + hvac_client.write.return_value = {'wrap_info': {'token': 'foo'}} + self.assertEqual( + vault.generate_role_secret_id(hvac_client, + 'testrole', + '10.5.10.10/32'), + 'foo' + ) + hvac_client.write.assert_called_with( + 'auth/approle/role/testrole/secret-id', + wrap_ttl='1h', cidr_list='10.5.10.10/32' + ) + def test_configure_policy(self): hvac_client = mock.MagicMock() vault.configure_policy(hvac_client, 'test-policy', 'test-hcl') @@ -354,7 +368,7 @@ class TestLibCharmVault(unit_tests.test_utils.CharmTestCase): token_ttl='60s', token_max_ttl='60s', policies=['test-policy'], - bind_secret_id='false', + bind_secret_id='true', bound_cidr_list='10.5.0.20/32' ) hvac_client.get_role_id.assert_called_with('test-role') diff --git a/unit_tests/test_reactive_vault_handlers.py b/unit_tests/test_reactive_vault_handlers.py index ada1a03..a41159f 100644 --- a/unit_tests/test_reactive_vault_handlers.py +++ b/unit_tests/test_reactive_vault_handlers.py @@ -508,6 +508,8 @@ class TestHandlers(unit_tests.test_utils.CharmTestCase): _vault.configure_approle.side_effect = ['role_a', 'role_b'] self.is_flag_set.return_value = False _vault.get_api_url.return_value = "http://vault:8200" + hvac_client.list_roles.return_value = [] + _vault.generate_role_secret_id.return_value = 'mysecret' handlers.configure_secrets_backend() @@ -533,12 +535,17 @@ class TestHandlers(unit_tests.test_utils.CharmTestCase): secrets_interface.set_role_id.assert_has_calls([ mock.call(unit=mock.ANY, - role_id='role_a'), + role_id='role_a', + token='mysecret'), mock.call(unit=mock.ANY, - role_id='role_b'), + role_id='role_b', + token='mysecret'), ]) - self.clear_flag.assert_called_once_with('endpoint.secrets.new-request') + self.clear_flag.assert_has_calls([ + mock.call('endpoint.secrets.new-request'), + mock.call('secrets.refresh'), + ]) @mock.patch.object(handlers, 'vault') def send_vault_url_and_ca(self, _vault):