diff --git a/config.yaml b/config.yaml index 45cac84b..cabd4577 100644 --- a/config.yaml +++ b/config.yaml @@ -96,6 +96,18 @@ options: type: string default: 'Admin' description: Admin role to be associated with admin and service users. + token-provider: + type: string + default: 'uuid' + description: | + Transitional configuration option to enable migration to Fernet tokens + prior to upgrade to OpenStack Rocky. + . + Supported values are 'uuid' and 'fernet'. + . + NOTE: This configuration option is honoured on OpenStack versions Ocata + through Queens. For OpenStack Rocky it is a unconfigurable default. + Silently ignored for all other versions. token-expiration: type: int default: 3600 diff --git a/hooks/keystone_context.py b/hooks/keystone_context.py index 697f10da..c4595f2c 100644 --- a/hooks/keystone_context.py +++ b/hooks/keystone_context.py @@ -174,6 +174,7 @@ class KeystoneContext(context.OSContextGenerator): ctxt['identity_backend'] = config('identity-backend') ctxt['assignment_backend'] = config('assignment-backend') + ctxt['token_provider'] = config('token-provider') if config('identity-backend') == 'ldap': ctxt['ldap_server'] = config('ldap-server') ctxt['ldap_user'] = config('ldap-user') diff --git a/hooks/keystone_hooks.py b/hooks/keystone_hooks.py index 53ccae31..cfd938e2 100755 --- a/hooks/keystone_hooks.py +++ b/hooks/keystone_hooks.py @@ -101,6 +101,10 @@ from keystone_utils import ( ADMIN_PROJECT, create_or_show_domain, restart_keystone, + fernet_enabled, + fernet_leader_set, + fernet_setup, + fernet_write_keys, ) from charmhelpers.contrib.hahelpers.cluster import ( @@ -222,6 +226,10 @@ def config_changed_postupgrade(): # packages may have changed so ensure they are installed. apt_install(filter_installed_packages(determine_packages())) + if is_leader() and fernet_enabled(): + fernet_setup() + fernet_leader_set() + configure_https() open_port(config('service-port')) @@ -235,6 +243,9 @@ def config_changed_postupgrade(): if (is_db_initialised() and is_elected_leader(CLUSTER_RES) and not is_unit_paused_set()): ensure_initial_admin(config) + if CompareOpenStackReleases( + os_release('keystone')) >= 'liberty': + CONFIGS.write(POLICY_JSON) update_all_identity_relation_units() update_all_domain_backends() @@ -332,6 +343,9 @@ def leader_init_db_if_ready(use_current_context=False): migrate_database() ensure_initial_admin(config) + if CompareOpenStackReleases( + os_release('keystone')) >= 'liberty': + CONFIGS.write(POLICY_JSON) # Ensure any existing service entries are updated in the # new database backend. Also avoid duplicate db ready check. update_all_identity_relation_units(check_db_ready=False) @@ -483,7 +497,12 @@ def leader_settings_changed(): CONFIGS.write(TOKEN_FLUSH_CRON_FILE) # Make sure we keep domain and/or project ids used in templates up to date - CONFIGS.write(POLICY_JSON) + if CompareOpenStackReleases( + os_release('keystone')) >= 'liberty': + CONFIGS.write(POLICY_JSON) + + if fernet_enabled(): + fernet_write_keys() update_all_identity_relation_units() diff --git a/hooks/keystone_utils.py b/hooks/keystone_utils.py index 47ccea0a..d41650ed 100644 --- a/hooks/keystone_utils.py +++ b/hooks/keystone_utils.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os import shutil import subprocess @@ -96,12 +97,14 @@ from charmhelpers.fetch import ( ) from charmhelpers.core.host import ( + mkdir, service_restart, service_stop, service_start, pwgen, lsb_release, CompareHostReleases, + write_file, ) from charmhelpers.contrib.peerstorage import ( @@ -1915,3 +1918,56 @@ def restart_keystone(): service_restart('snap.keystone.*') else: service_restart(keystone_service()) + + +def fernet_enabled(): + """Helper function for determinining whether Fernet tokens are enabled""" + cmp_release = CompareOpenStackReleases(os_release('keystone')) + if cmp_release < 'ocata': + return False + elif 'ocata' >= cmp_release < 'rocky': + return config('token-provider') == 'fernet' + else: + return True + + +def fernet_setup(): + """Initialize Fernet key repositories""" + base_cmd = ['sudo', '-u', 'keystone', 'keystone-manage'] + subprocess.check_output(base_cmd + ['fernet_setup']) + subprocess.check_output(base_cmd + ['credential_setup']) + + +def fernet_rotate(): + """Rotate Fernet keys""" + cmd = ['sudo', '-u', 'keystone', 'keystone-manage', 'fernet_rotate'] + subprocess.check_output(cmd) + + +def fernet_leader_set(): + """Read current key set and update leader storage if necessary""" + key_repository = '/etc/keystone/fernet-keys/' + disk_keys = [] + for key in os.listdir(key_repository): + with open(os.path.join(key_repository, str(key)), 'r') as f: + disk_keys.append(f.read()) + leader_set({'fernet_keys': json.dumps(disk_keys)}) + + +def fernet_write_keys(): + """Get keys from leader storage and write out to disk""" + key_repository = '/etc/keystone/fernet-keys/' + leader_keys = leader_get('fernet_keys') + if not leader_keys: + log('"fernet_keys" not in leader settings yet...', level=DEBUG) + return + mkdir(key_repository, owner=KEYSTONE_USER, group=KEYSTONE_USER, + perms=0o700) + for idx, key in enumerate(json.loads(leader_keys)): + tmp_filename = os.path.join(key_repository, "."+str(idx)) + key_filename = os.path.join(key_repository, str(idx)) + # write to tmp file first, move the key into place in an atomic + # operation avoiding any races with consumers of the key files + write_file(tmp_filename, key, owner=KEYSTONE_USER, group=KEYSTONE_USER, + perms=0o600) + os.rename(tmp_filename, key_filename) diff --git a/templates/ocata/keystone.conf b/templates/ocata/keystone.conf index 352d22be..22ecf7c2 100644 --- a/templates/ocata/keystone.conf +++ b/templates/ocata/keystone.conf @@ -44,8 +44,12 @@ driver = sql [endpoint_filter] [token] +{% if token_provider == 'fernet' -%} +provider = fernet +{% else -%} driver = sql provider = uuid +{% endif -%} expiration = {{ token_expiration }} {% include "parts/section-signing" %} diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py index 192c11ff..d9444170 100644 --- a/tests/basic_deployment.py +++ b/tests/basic_deployment.py @@ -143,6 +143,10 @@ class KeystoneBasicDeployment(OpenStackAmuletDeployment): 'admin-password': 'openstack', 'admin-token': 'ubuntutesting', 'preferred-api-version': self.keystone_api_version, + # NOTE(fnordahl): honoured on OpenStack versions Ocata + # through Queens, default and not configurable for Rocky, + # ignored for all other versions. + 'token-provider': 'fernet', }) pxc_config = { diff --git a/unit_tests/test_keystone_hooks.py b/unit_tests/test_keystone_hooks.py index a025ddee..975475c5 100644 --- a/unit_tests/test_keystone_hooks.py +++ b/unit_tests/test_keystone_hooks.py @@ -89,6 +89,10 @@ TO_PATCH = [ 'is_db_ready', 'create_or_show_domain', 'get_api_version', + 'fernet_enabled', + 'fernet_leader_set', + 'fernet_setup', + 'fernet_write_keys', # other 'check_call', 'execd_preinstall', @@ -455,6 +459,7 @@ class KeystoneRelationTests(CharmTestCase): @patch.object(hooks, 'update_all_identity_relation_units') @patch.object(hooks.CONFIGS, 'write') def test_leader_settings_changed(self, mock_write, update): + self.os_release.return_value = 'mitaka' self.relation_ids.return_value = ['identity:1'] self.related_units.return_value = ['keystone/1'] hooks.leader_settings_changed() @@ -712,6 +717,7 @@ class KeystoneRelationTests(CharmTestCase): self.is_elected_leader.return_value = True is_db_initialized.return_value = False self.is_db_ready.return_value = True + self.os_release.return_value = 'mitaka' hooks.leader_init_db_if_ready() self.is_db_ready.assert_called_with(use_current_context=False) self.migrate_database.assert_called_with() diff --git a/unit_tests/test_keystone_utils.py b/unit_tests/test_keystone_utils.py index 97a40530..48075f78 100644 --- a/unit_tests/test_keystone_utils.py +++ b/unit_tests/test_keystone_utils.py @@ -12,10 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from mock import patch, call, MagicMock -from test_utils import CharmTestCase +import json import os import subprocess +import sys + +from mock import MagicMock, call, mock_open, patch +from test_utils import CharmTestCase + +if sys.version_info.major == 2: + import __builtin__ as builtins +else: + import builtins os.environ['JUJU_UNIT_NAME'] = 'keystone' with patch('charmhelpers.core.hookenv.config') as config, \ @@ -53,6 +61,8 @@ TO_PATCH = [ 'related_units', 'https', 'peer_store', + 'mkdir', + 'write_file', # generic 'apt_update', 'apt_upgrade', @@ -756,12 +766,6 @@ class TestKeystoneUtils(CharmTestCase): isfile_mock.return_value = False x = utils.get_file_stored_domain_id('/a/file') assert x is None - from sys import version_info - if version_info.major == 2: - import __builtin__ as builtins - else: - import builtins - from mock import mock_open with patch.object(builtins, 'open', mock_open( read_data="some_data\n")): isfile_mock.return_value = True @@ -1124,3 +1128,80 @@ class TestKeystoneUtils(CharmTestCase): self.get_os_codename_install_source.return_value = 'queens' with self.assertRaises(ValueError): utils.get_api_version() + + def test_fernet_enabled_no_config(self): + self.os_release.return_value = 'ocata' + self.test_config.set('token-provider', 'uuid') + result = utils.fernet_enabled() + self.assertFalse(result) + + def test_fernet_enabled_yes_config(self): + self.os_release.return_value = 'ocata' + self.test_config.set('token-provider', 'fernet') + result = utils.fernet_enabled() + self.assertTrue(result) + + def test_fernet_enabled_no_release_override_config(self): + self.os_release.return_value = 'mitaka' + self.test_config.set('token-provider', 'fernet') + result = utils.fernet_enabled() + self.assertFalse(result) + + def test_fernet_enabled_yes_release(self): + self.os_release.return_value = 'rocky' + result = utils.fernet_enabled() + self.assertTrue(result) + + def test_fernet_enabled_yes_release_override_config(self): + self.os_release.return_value = 'rocky' + self.test_config.set('token-provider', 'uuid') + result = utils.fernet_enabled() + self.assertTrue(result) + + def test_fernet_setup(self): + base_cmd = ['sudo', '-u', 'keystone', 'keystone-manage'] + utils.fernet_setup() + self.subprocess.check_output.has_calls( + [ + base_cmd + ['fernet_setup'], + base_cmd + ['credential_setup'], + ]) + + def test_fernet_rotate(self): + cmd = ['sudo', '-u', 'keystone', 'keystone-manage', 'fernet_rotate'] + utils.fernet_rotate() + self.subprocess.check_output.called_with(cmd) + + @patch.object(utils, 'leader_set') + @patch('os.listdir') + def test_fernet_leader_set(self, listdir, leader_set): + listdir.return_value = [0, 1] + with patch.object(builtins, 'open', mock_open( + read_data="some_data")): + utils.fernet_leader_set() + listdir.assert_called_with('/etc/keystone/fernet-keys/') + leader_set.assert_called_with( + {'fernet_keys': json.dumps(['some_data', 'some_data'])}) + + @patch('os.rename') + @patch.object(utils, 'leader_get') + def test_fernet_write_keys(self, leader_get, rename): + key_repository = '/etc/keystone/fernet-keys/' + leader_get.return_value = json.dumps(['key0', 'key1']) + utils.fernet_write_keys() + self.mkdir.assert_called_with(key_repository, owner='keystone', + group='keystone', perms=0o700) + self.write_file.assert_has_calls( + [ + call(os.path.join(key_repository, '.0'), u'key0', + owner='keystone', group='keystone', perms=0o600), + call(os.path.join(key_repository, '.1'), u'key1', + owner='keystone', group='keystone', perms=0o600), + ]) + rename.assert_has_calls( + [ + call(os.path.join(key_repository, '.0'), + os.path.join(key_repository, '0')), + call(os.path.join(key_repository, '.1'), + os.path.join(key_repository, '1')), + ])