Add initial support for Fernet tokens

Starting OpenStack Rocky the currently used `uuid` token format
is no longer supported and we need to change to use `fernet` tokens.

This change provides basic functionalty to initialize fernet token
repository and distribute keys to non-leader units.

A configuration option is also added allowing change of token format
in a controlled manner prior to upgrading to OpenStack Rocky.

Further work is required to implement key rotation, actions etc. and
these topics will be addressed in separate commits.

The commit also fixes a instance of missing release check for writing
of `policy.json`, and a few places where writing of `policy.json`
previously was omitted.

Change-Id: I1d0ff22a5f091b02f5700412745572c246103e9e
This commit is contained in:
Frode Nordahl 2018-07-24 12:38:37 +02:00
parent ac40485052
commit 1e991dc28b
No known key found for this signature in database
GPG Key ID: 6A5D59A3BA48373F
8 changed files with 192 additions and 9 deletions

View File

@ -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

View File

@ -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')

View File

@ -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()

View File

@ -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)

View File

@ -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" %}

View File

@ -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 = {

View File

@ -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()

View File

@ -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')),
])