From ae178d74711f548fe3fd3dda0568492aafe5b216 Mon Sep 17 00:00:00 2001 From: Pedro Castillo Date: Tue, 8 Mar 2022 15:36:29 +0000 Subject: [PATCH] Add rotate-admin-password action This action allows the user to easily rotate the admin user's password by replacing it with a randomly generated one. Change-Id: I6ce69be15b11b00f804d3143d835ec3ce6515865 Related-Bug: #1927280 Func-Test-PR: https://github.com/openstack-charmers/zaza-openstack-tests/pull/720 --- actions.yaml | 7 +++- actions/actions.py | 15 ++++++++- actions/rotate-admin-password | 1 + hooks/keystone_utils.py | 16 +++++++++ unit_tests/test_actions.py | 11 +++++++ unit_tests/test_keystone_utils.py | 54 +++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 120000 actions/rotate-admin-password diff --git a/actions.yaml b/actions.yaml index 243fde90..1fb080e6 100644 --- a/actions.yaml +++ b/actions.yaml @@ -9,6 +9,11 @@ resume: Resume keystone services. If the keystone deployment is clustered using the hacluster charm, the corresponding hacluster unit on the node must be resumed as well. +rotate-admin-password: + description: | + Rotate the admin user's password. + The current password is replaced with a randomly generated password. The + new password is stored in the leader's admin_passwd bucket. openstack-upgrade: description: | Perform openstack upgrades. Config option action-managed-upgrade must be @@ -18,4 +23,4 @@ security-checklist: Validate the running configuration against the OpenStack security guides checklist. get-admin-password: - description: Retrieve the admin password for the Keystone service. \ No newline at end of file + description: Retrieve the admin password for the Keystone service. diff --git a/actions/actions.py b/actions/actions.py index 15147b76..c91dfff3 100755 --- a/actions/actions.py +++ b/actions/actions.py @@ -33,12 +33,21 @@ _add_path(_root) from charmhelpers.core.hookenv import action_fail from keystone_utils import ( + rotate_admin_passwd, pause_unit_helper, resume_unit_helper, register_configs, ) +def rotate_admin_password(args): + """Rotate the admin user's password. + + @raises Exception if keystone client cannot update the password + """ + rotate_admin_passwd() + + def pause(args): """Pause all the Keystone services. @@ -57,7 +66,11 @@ def resume(args): # A dictionary of all the defined actions to callables (which take # parsed arguments). -ACTIONS = {"pause": pause, "resume": resume} +ACTIONS = { + "rotate-admin-password": rotate_admin_password, + "pause": pause, + "resume": resume, +} def main(args): diff --git a/actions/rotate-admin-password b/actions/rotate-admin-password new file mode 120000 index 00000000..405a394e --- /dev/null +++ b/actions/rotate-admin-password @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/hooks/keystone_utils.py b/hooks/keystone_utils.py index f9195db3..8788759d 100644 --- a/hooks/keystone_utils.py +++ b/hooks/keystone_utils.py @@ -1500,6 +1500,22 @@ def set_admin_passwd(passwd, user=None): _leader_set_secret({'{}_passwd'.format(user): passwd}) +def rotate_admin_passwd(): + if not is_leader(): + raise RuntimeError("This unit is not the leader and therefore can't " + "rotate the admin password.") + admin_passwd = config('admin-password') + if admin_passwd and admin_passwd.strip().lower() != 'none': + raise RuntimeError( + "The 'admin-password' config is present, so the action will be " + "aborted. To allow randomly generated passwords, unset the " + "config value.") + user = config('admin-user') + new_passwd = pwgen(16) + update_user_password(user, new_passwd, ADMIN_DOMAIN) + leader_set({'admin_passwd': new_passwd}) + + def get_api_version(): api_version = config('preferred-api-version') cmp_release = CompareOpenStackReleases( diff --git a/unit_tests/test_actions.py b/unit_tests/test_actions.py index 842f8396..174ef121 100644 --- a/unit_tests/test_actions.py +++ b/unit_tests/test_actions.py @@ -25,6 +25,17 @@ with patch('charmhelpers.contrib.openstack.utils.' import actions.actions +class ChangeAdminPasswordTestCase(CharmTestCase): + + def setUp(self): + super(ChangeAdminPasswordTestCase, self).setUp( + actions.actions, ["rotate_admin_passwd"]) + + def test_rotate_admin_password(self): + actions.actions.rotate_admin_password([]) + self.rotate_admin_passwd.assert_called_once() + + class PauseTestCase(CharmTestCase): def setUp(self): diff --git a/unit_tests/test_keystone_utils.py b/unit_tests/test_keystone_utils.py index f0327e1a..3574b36c 100644 --- a/unit_tests/test_keystone_utils.py +++ b/unit_tests/test_keystone_utils.py @@ -2131,3 +2131,57 @@ class TestKeystoneUtils(CharmTestCase): self.assertEqual( utils.get_add_role_to_admin({}), []) + + @patch.object(utils, 'update_user_password') + @patch.object(utils, 'leader_set') + @patch.object(utils, 'pwgen') + @patch.object(utils, 'is_leader') + def test_rotate_admin_password_without_config( + self, is_leader, pwgen, leader_set, update_user_password): + user = 'test-user' + password = 'password' + is_leader.return_value = True + pwgen.return_value = password + self.test_config.set('admin-user', user) + self.test_config.set('admin-password', '') + + utils.rotate_admin_passwd() + + pwgen.assert_called_once() + update_user_password.assert_called_once_with( + user, password, utils.ADMIN_DOMAIN) + leader_set.assert_called_once_with({'admin_passwd': password}) + + @patch.object(utils, 'update_user_password') + @patch.object(utils, 'leader_set') + @patch.object(utils, 'pwgen') + @patch.object(utils, 'is_leader') + def test_rotate_admin_password_with_config( + self, is_leader, pwgen, leader_set, update_user_password): + user = 'test-user' + password = 'password' + is_leader.return_value = True + self.test_config.set('admin-user', user) + self.test_config.set('admin-password', password) + + with self.assertRaises(RuntimeError): + utils.rotate_admin_passwd() + + pwgen.assert_not_called() + update_user_password.assert_not_called() + leader_set.assert_not_called() + + @patch.object(utils, 'update_user_password') + @patch.object(utils, 'leader_set') + @patch.object(utils, 'pwgen') + @patch.object(utils, 'is_leader') + def test_rotate_admin_password_outside_leader( + self, is_leader, pwgen, leader_set, update_user_password): + is_leader.return_value = False + + with self.assertRaises(RuntimeError): + utils.rotate_admin_passwd() + + pwgen.assert_not_called() + update_user_password.assert_not_called() + leader_set.assert_not_called()