Implement backend driver support for domain config

Add support for the backend driver storage for domain configs, both
whitelisted and sensitive. A subsequent patch will add the support in
the manager layer for actually implementing the whitelist checking,
using the underlying driver provided in this patch.

Partially Implements: blueprint domain-config-ext
Change-Id: Id98da28f83ccc1e81d20cfda96da895edcc8a703
This commit is contained in:
Henry Nash 2015-02-20 15:52:46 +00:00
parent ec8f6070ab
commit 508b11a5b8
11 changed files with 561 additions and 2 deletions

View File

@ -43,6 +43,7 @@ def load_backends():
assignment_api=_ASSIGNMENT_API,
catalog_api=catalog.Manager(),
credential_api=credential.Manager(),
domain_config_api=resource.DomainConfigManager(),
endpoint_filter_api=endpoint_filter.Manager(),
endpoint_policy_api=endpoint_policy.Manager(),
federation_api=federation.Manager(),

View File

@ -445,6 +445,12 @@ FILE_OPTIONS = {
help='Maximum number of entities that will be returned '
'in a resource collection.'),
],
'domain_config': [
cfg.StrOpt('driver',
default='keystone.resource.config_backends.sql.'
'DomainConfig',
help='Domain config backend driver.'),
],
'role': [
# The role driver has no default for backward compatibility reasons.
# If role driver is not specified, the assignment driver chooses

View File

@ -0,0 +1,55 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import sqlalchemy as sql
from keystone.common import sql as ks_sql
WHITELIST_TABLE = 'whitelisted_config'
SENSITIVE_TABLE = 'sensitive_config'
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
whitelist_table = sql.Table(
WHITELIST_TABLE,
meta,
sql.Column('domain_id', sql.String(64), primary_key=True),
sql.Column('group', sql.String(255), primary_key=True),
sql.Column('option', sql.String(255), primary_key=True),
sql.Column('value', ks_sql.JsonBlob.impl, nullable=False),
mysql_engine='InnoDB',
mysql_charset='utf8')
whitelist_table.create(migrate_engine, checkfirst=True)
sensitive_table = sql.Table(
SENSITIVE_TABLE,
meta,
sql.Column('domain_id', sql.String(64), primary_key=True),
sql.Column('group', sql.String(255), primary_key=True),
sql.Column('option', sql.String(255), primary_key=True),
sql.Column('value', ks_sql.JsonBlob.impl, nullable=False),
mysql_engine='InnoDB',
mysql_charset='utf8')
sensitive_table.create(migrate_engine, checkfirst=True)
def downgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
table = sql.Table(WHITELIST_TABLE, meta, autoload=True)
table.drop(migrate_engine, checkfirst=True)
table = sql.Table(SENSITIVE_TABLE, meta, autoload=True)
table.drop(migrate_engine, checkfirst=True)

View File

@ -335,6 +335,12 @@ class PublicIDNotFound(NotFound):
message_format = "%(id)s"
class DomainConfigNotFound(NotFound):
message_format = _('Could not find Domain Configuration for domain: '
'%(domain_id)s, for group: %(group)s and '
'option: %(option)s')
class Conflict(Error):
message_format = _("Conflict occurred attempting to store %(type)s -"
" %(details)s")

View File

@ -0,0 +1,116 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from keystone.common import sql
from keystone import exception
from keystone import resource
class WhiteListedConfig(sql.ModelBase, sql.ModelDictMixin):
__tablename__ = 'whitelisted_config'
domain_id = sql.Column(sql.String(64), primary_key=True)
group = sql.Column(sql.String(255), primary_key=True)
option = sql.Column(sql.String(255), primary_key=True)
value = sql.Column(sql.JsonBlob(), nullable=False)
def to_dict(self):
d = super(WhiteListedConfig, self).to_dict()
d.pop('domain_id')
return d
class SensitiveConfig(sql.ModelBase, sql.ModelDictMixin):
__tablename__ = 'sensitive_config'
domain_id = sql.Column(sql.String(64), primary_key=True)
group = sql.Column(sql.String(255), primary_key=True)
option = sql.Column(sql.String(255), primary_key=True)
value = sql.Column(sql.JsonBlob(), nullable=False)
def to_dict(self):
d = super(SensitiveConfig, self).to_dict()
d.pop('domain_id')
return d
class DomainConfig(resource.DomainConfigDriver):
def choose_table(self, sensitive):
if sensitive:
return SensitiveConfig
else:
return WhiteListedConfig
@sql.handle_conflicts(conflict_type='domain_config')
def create_config_option(self, domain_id, group, option, value,
sensitive=False):
with sql.transaction() as session:
config_table = self.choose_table(sensitive)
ref = config_table(domain_id=domain_id, group=group,
option=option, value=value)
session.add(ref)
return ref.to_dict()
def _get_config_option(self, session, domain_id, group, option, sensitive):
try:
config_table = self.choose_table(sensitive)
ref = (session.query(config_table).
filter_by(domain_id=domain_id, group=group,
option=option).one())
except sql.NotFound:
raise exception.DomainConfigNotFound(
domain_id=domain_id, group=group, option=option)
return ref
def get_config_option(self, domain_id, group, option, sensitive=False):
with sql.transaction() as session:
ref = self._get_config_option(session, domain_id, group, option,
sensitive)
return ref.to_dict()
def list_config_options(self, domain_id, group=None, option=None,
sensitive=False):
with sql.transaction() as session:
config_table = self.choose_table(sensitive)
query = session.query(config_table)
query = query.filter_by(domain_id=domain_id)
if group:
query = query.filter_by(group=group)
if option:
query = query.filter_by(option=option)
return [ref.to_dict() for ref in query.all()]
def update_config_option(self, domain_id, group, option, value,
sensitive=False):
with sql.transaction() as session:
ref = self._get_config_option(session, domain_id, group, option,
sensitive)
ref.value = value
return ref.to_dict()
def delete_config_options(self, domain_id, group=None, option=None,
sensitive=False):
"""Deletes config options that match the filter parameters.
Since the public API is broken down into calls for delete in both the
whitelisted and sensitive methods, we are silent at the driver level
if there was nothing to delete.
"""
with sql.transaction() as session:
config_table = self.choose_table(sensitive)
query = session.query(config_table)
query = query.filter_by(domain_id=domain_id)
if group:
query = query.filter_by(group=group)
if option:
query = query.filter_by(option=option)
query.delete(False)

View File

@ -47,8 +47,8 @@ def calc_default_domain():
@dependency.provider('resource_api')
@dependency.requires('assignment_api', 'credential_api', 'identity_api',
'revoke_api')
@dependency.requires('assignment_api', 'credential_api', 'domain_config_api',
'identity_api', 'revoke_api')
class Manager(manager.Manager):
"""Default pivot point for the resource backend.
@ -446,6 +446,9 @@ class Manager(manager.Manager):
'please disable it first.'))
self._delete_domain_contents(domain_id)
# Delete any database stored domain config
self.domain_config_api.delete_config_options(domain_id)
self.domain_config_api.delete_config_options(domain_id, sensitive=True)
# TODO(henry-nash): Although the controller will ensure deletion of
# all users & groups within the domain (which will cause all
# assignments for those users/groups to also be deleted), there
@ -782,3 +785,105 @@ class Driver(object):
"""
if domain_id != CONF.identity.default_domain_id:
raise exception.DomainNotFound(domain_id=domain_id)
@dependency.provider('domain_config_api')
class DomainConfigManager(manager.Manager):
"""Default pivot point for the Domain Config backend."""
def __init__(self):
super(DomainConfigManager, self).__init__(CONF.domain_config.driver)
# TODO(henry-nash): The manager layer will handle all the whitelist
# checking of the config options, using the appropriate driver methods for
# the whitelisted and sensitive data.
@six.add_metaclass(abc.ABCMeta)
class DomainConfigDriver(object):
"""Interface description for a Domain Config driver."""
@abc.abstractmethod
def create_config_option(self, domain_id, group, option, value,
sensitive=False):
"""Creates a config option for a domain.
:param domain_id: the domain for this option
:param group: the group name
:param option: the option name
:param value: the value to assign to this option
:param sensitive: whether the option is sensitive
:returns: dict containing group, option and value
:raises: keystone.exception.Conflict
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def get_config_option(self, domain_id, group, option, sensitive=False):
"""Gets the config option for a domain.
:param domain_id: the domain for this option
:param group: the group name
:param option: the option name
:param sensitive: whether the option is sensitive
:returns: dict containing group, option and value
:raises: keystone.exception.DomainConfigNotFound: the option doesn't
exist.
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def list_config_options(self, domain_id, group=None, option=False,
sensitive=False):
"""Gets a config options for a domain.
:param domain_id: the domain for this option
:param group: optional group option name
:param option: optional option name. If group is None, then this
paramater is ignored
:param sensitive: whether the option is sensitive
:returns: list of dicts containing group, option and value
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def update_config_option(self, domain_id, group, option, value,
sensitive=False):
"""Updates a config option for a domain.
:param domain_id: the domain for this option
:param group: the group option name
:param option: the option name
:param value: the value to assign to this option
:param sensitive: whether the option is sensitive
:returns: dict containing updated group, option and value
:raises: keystone.exception.DomainConfigNotFound: the option doesn't
exist.
"""
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
def delete_config_options(self, domain_id, group=None, option=None,
sensitive=False):
"""Deletes config options for a domain.
Allows deletion of all options for a domain, all options in a group
or a specific option. The driver is silent if there are no options
to delete.
:param domain_id: the domain for this option
:param group: optional group option name
:param option: optional option name. If group is None, then this
paramater is ignored
:param sensitive: whether the option is sensitive
"""
raise exception.NotImplemented() # pragma: no cover

View File

@ -0,0 +1,214 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import uuid
from testtools import matchers
from keystone import exception
class DomainConfigTests(object):
def setUp(self):
self.domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex}
self.resource_api.create_domain(self.domain['id'], self.domain)
self.addCleanup(self.clean_up_domain)
def clean_up_domain(self):
# NOTE(henry-nash): Deleting the domain will also delete any domain
# configs for this domain.
self.domain['enabled'] = False
self.resource_api.update_domain(self.domain['id'], self.domain)
self.resource_api.delete_domain(self.domain['id'])
del self.domain
def _domain_config_crud(self, sensitive):
group = uuid.uuid4().hex
option = uuid.uuid4().hex
value = uuid.uuid4().hex
self.domain_config_api.create_config_option(
self.domain['id'], group, option, value, sensitive)
res = self.domain_config_api.get_config_option(
self.domain['id'], group, option, sensitive)
config = {'group': group, 'option': option, 'value': value}
self.assertEqual(config, res)
value = uuid.uuid4().hex
self.domain_config_api.update_config_option(
self.domain['id'], group, option, value, sensitive)
res = self.domain_config_api.get_config_option(
self.domain['id'], group, option, sensitive)
config = {'group': group, 'option': option, 'value': value}
self.assertEqual(config, res)
self.domain_config_api.delete_config_options(
self.domain['id'], group, option, sensitive)
self.assertRaises(exception.DomainConfigNotFound,
self.domain_config_api.get_config_option,
self.domain['id'], group, option, sensitive)
# ...and silent if we try to delete it again
self.domain_config_api.delete_config_options(
self.domain['id'], group, option, sensitive)
def test_whitelisted_domain_config_crud(self):
self._domain_config_crud(sensitive=False)
def test_sensitive_domain_config_crud(self):
self._domain_config_crud(sensitive=True)
def _list_domain_config(self, sensitive):
"""Test listing by combination of domain, group & option."""
config1 = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
# Put config2 in the same group as config1
config2 = {'group': config1['group'], 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
config3 = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': 100}
for config in [config1, config2, config3]:
self.domain_config_api.create_config_option(
self.domain['id'], config['group'], config['option'],
config['value'], sensitive)
# Try listing all items from a domain
res = self.domain_config_api.list_config_options(
self.domain['id'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(3))
for res_entry in res:
self.assertIn(res_entry, [config1, config2, config3])
# Try listing by domain and group
res = self.domain_config_api.list_config_options(
self.domain['id'], group=config1['group'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(2))
for res_entry in res:
self.assertIn(res_entry, [config1, config2])
# Try listing by domain, group and option
res = self.domain_config_api.list_config_options(
self.domain['id'], group=config2['group'],
option=config2['option'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(1))
self.assertEqual(config2, res[0])
def test_list_whitelisted_domain_config_crud(self):
self._list_domain_config(False)
def test_list_sensitive_domain_config_crud(self):
self._list_domain_config(True)
def _delete_domain_configs(self, sensitive):
"""Test deleting by combination of domain, group & option."""
config1 = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
# Put config2 and config3 in the same group as config1
config2 = {'group': config1['group'], 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
config3 = {'group': config1['group'], 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
config4 = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
for config in [config1, config2, config3, config4]:
self.domain_config_api.create_config_option(
self.domain['id'], config['group'], config['option'],
config['value'], sensitive)
# Try deleting by domain, group and option
res = self.domain_config_api.delete_config_options(
self.domain['id'], group=config2['group'],
option=config2['option'], sensitive=sensitive)
res = self.domain_config_api.list_config_options(
self.domain['id'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(3))
for res_entry in res:
self.assertIn(res_entry, [config1, config3, config4])
# Try deleting by domain and group
res = self.domain_config_api.delete_config_options(
self.domain['id'], group=config4['group'], sensitive=sensitive)
res = self.domain_config_api.list_config_options(
self.domain['id'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(2))
for res_entry in res:
self.assertIn(res_entry, [config1, config3])
# Try deleting all items from a domain
res = self.domain_config_api.delete_config_options(
self.domain['id'], sensitive=sensitive)
res = self.domain_config_api.list_config_options(
self.domain['id'], sensitive=sensitive)
self.assertThat(res, matchers.HasLength(0))
def test_delete_whitelisted_domain_configs(self):
self._delete_domain_configs(False)
def test_delete_sensitive_domain_configs(self):
self._delete_domain_configs(True)
def _create_domain_config_twice(self, sensitive):
"""Test conflict error thrown if create the same option twice."""
config = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
self.domain_config_api.create_config_option(
self.domain['id'], config['group'], config['option'],
config['value'], sensitive=sensitive)
self.assertRaises(exception.Conflict,
self.domain_config_api.create_config_option,
self.domain['id'], config['group'], config['option'],
config['value'], sensitive=sensitive)
def test_create_whitelisted_domain_config_twice(self):
self._create_domain_config_twice(False)
def test_create_sensitive_domain_config_twice(self):
self._create_domain_config_twice(True)
def test_delete_domain_deletes_configs(self):
"""Test domain deletion clears the domain configs."""
domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex}
self.resource_api.create_domain(domain['id'], domain)
config1 = {'group': uuid.uuid4().hex, 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
# Put config2 in the same group as config1
config2 = {'group': config1['group'], 'option': uuid.uuid4().hex,
'value': uuid.uuid4().hex}
self.domain_config_api.create_config_option(
domain['id'], config1['group'], config1['option'],
config1['value'])
self.domain_config_api.create_config_option(
domain['id'], config2['group'], config2['option'],
config2['value'], sensitive=True)
res = self.domain_config_api.list_config_options(
domain['id'])
self.assertThat(res, matchers.HasLength(1))
res = self.domain_config_api.list_config_options(
domain['id'], sensitive=True)
self.assertThat(res, matchers.HasLength(1))
# Now delete the domain
domain['enabled'] = False
self.resource_api.update_domain(domain['id'], domain)
self.resource_api.delete_domain(domain['id'])
# Check domain configs have also been deleted
res = self.domain_config_api.list_config_options(
domain['id'])
self.assertThat(res, matchers.HasLength(0))
res = self.domain_config_api.list_config_options(
domain['id'], sensitive=True)
self.assertThat(res, matchers.HasLength(0))

View File

@ -0,0 +1,41 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from keystone.common import sql
from keystone.tests.unit.backend import core_sql
from keystone.tests.unit.backend.domain_config import core
class SqlDomainConfigModels(core_sql.BaseBackendSqlModels):
def test_whitelisted_model(self):
cols = (('domain_id', sql.String, 64),
('group', sql.String, 255),
('option', sql.String, 255),
('value', sql.JsonBlob, None))
self.assertExpectedSchema('whitelisted_config', cols)
def test_sensitive_model(self):
cols = (('domain_id', sql.String, 64),
('group', sql.String, 255),
('option', sql.String, 255),
('value', sql.JsonBlob, None))
self.assertExpectedSchema('sensitive_config', cols)
class SqlDomainConfig(core_sql.BaseBackendSqlTests, core.DomainConfigTests):
def setUp(self):
super(SqlDomainConfig, self).setUp()
# core.DomainConfigTests is effectively a mixin class, so make sure we
# call its setup
core.DomainConfigTests.setUp(self)

View File

@ -1553,6 +1553,21 @@ class SqlUpgradeTests(SqlMigrateBase):
self.assertTrue(self.does_fk_exist('group', 'domain_id'))
self.assertTrue(self.does_fk_exist('user', 'domain_id'))
def test_add_domain_config(self):
whitelisted_table = 'whitelisted_config'
sensitive_table = 'sensitive_config'
self.upgrade(64)
self.assertTableDoesNotExist(whitelisted_table)
self.assertTableDoesNotExist(sensitive_table)
self.upgrade(65)
self.assertTableColumns(whitelisted_table,
['domain_id', 'group', 'option', 'value'])
self.assertTableColumns(sensitive_table,
['domain_id', 'group', 'option', 'value'])
self.downgrade(64)
self.assertTableDoesNotExist(whitelisted_table)
self.assertTableDoesNotExist(sensitive_table)
def populate_user_table(self, with_pass_enab=False,
with_pass_enab_domain=False):
# Populate the appropriate fields in the user