Code-Defined Resource-specific Options
Implement the code-defined resource specific options framework for the Identity subsystem (User). Options are defined in code in the keystone.identity.backends.resource_options. These options are extracted from the update dict, validated for a specific type, and stored in an attribute association (as a one[User] to Many[one-per-option-type]) relation in a database table. On retrival options are converted from the association to elements in the user reference dict. Setting an option explicitly to None removes the option (unsets it). Updating a specific option will have no impact on other options. Change-Id: I90875ce2c3cf899557bb1f215fa1bda9576cbe83
This commit is contained in:
parent
c19f243152
commit
1896d1ba0d
|
@ -0,0 +1,193 @@
|
|||
# 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.
|
||||
|
||||
"""Options specific to resources managed by Keystone (Domain, User, etc)."""
|
||||
|
||||
import six
|
||||
|
||||
from keystone.i18n import _
|
||||
|
||||
|
||||
def _validator(value):
|
||||
return
|
||||
|
||||
|
||||
def ref_mapper_to_dict_options(ref):
|
||||
"""Convert the values in _resource_option_mapper to options dict.
|
||||
|
||||
NOTE: this is to be called from the relevant `to_dict` methods or
|
||||
similar and must be called from within the active session context.
|
||||
|
||||
:param ref: the DB model ref to extract options from
|
||||
:returns: Dict of options as expected to be returned out of to_dict in
|
||||
the `options` key.
|
||||
"""
|
||||
options = {}
|
||||
for opt in ref._resource_option_mapper.values():
|
||||
if opt.option_id in ref.resource_options_registry.option_ids:
|
||||
r_opt = ref.resource_options_registry.get_option_by_id(
|
||||
opt.option_id)
|
||||
if r_opt is not None:
|
||||
options[r_opt.option_name] = opt.option_value
|
||||
return options
|
||||
|
||||
|
||||
def resource_options_ref_to_mapper(ref, option_class):
|
||||
"""Convert the _resource_options property-dict to options attr map.
|
||||
|
||||
The model must have the resource option mapper located in the
|
||||
``_resource_option_mapper`` attribute.
|
||||
|
||||
The model must have the resource option registry located in the
|
||||
``resource_options_registry` attribute.
|
||||
|
||||
The option dict with key(opt_id), value(opt_value) will be pulled from
|
||||
``ref._resource_options``.
|
||||
|
||||
NOTE: This function MUST be called within the active writer session
|
||||
context!
|
||||
|
||||
:param ref: The DB model reference that is actually stored to the
|
||||
backend.
|
||||
:param option_class: Class that is used to store the resource option
|
||||
in the DB.
|
||||
"""
|
||||
options = getattr(ref, '_resource_options', None)
|
||||
if options is not None:
|
||||
# To ensure everything is clean, no lingering refs.
|
||||
delattr(ref, '_resource_options')
|
||||
else:
|
||||
# _resource_options didn't exist. Work from an empty set.
|
||||
options = {}
|
||||
|
||||
# NOTE(notmorgan): explicitly use .keys() here as the attribute mapper
|
||||
# has some oddities at times. This guarantees we are working with keys.
|
||||
set_options = set(ref._resource_option_mapper.keys())
|
||||
# Get any options that are not registered and slate them for removal from
|
||||
# the DB. This will delete unregistered options.
|
||||
clear_options = set_options.difference(
|
||||
ref.resource_options_registry.option_ids)
|
||||
options.update({x: None for x in clear_options})
|
||||
|
||||
# Set the resource options for user in the Attribute Mapping.
|
||||
for r_opt_id, r_opt_value in options.items():
|
||||
if r_opt_value is None:
|
||||
# Delete any option set explicitly to None, ignore unset
|
||||
# options.
|
||||
ref._resource_option_mapper.pop(r_opt_id, None)
|
||||
else:
|
||||
# Set any options on the user_ref itself.
|
||||
opt_obj = option_class(
|
||||
option_id=r_opt_id,
|
||||
option_value=r_opt_value)
|
||||
ref._resource_option_mapper[r_opt_id] = opt_obj
|
||||
|
||||
|
||||
class ResourceOptionRegistry(object):
|
||||
def __init__(self, registry_name):
|
||||
self._registered_options = {}
|
||||
self._registry_type = registry_name
|
||||
|
||||
@property
|
||||
def option_names(self):
|
||||
return set([opt.option_name for opt in self.options])
|
||||
|
||||
@property
|
||||
def options_by_name(self):
|
||||
return {opt.option_name: opt
|
||||
for opt in self._registered_options.values()}
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
return self._registered_options.values()
|
||||
|
||||
@property
|
||||
def option_ids(self):
|
||||
return set(self._registered_options.keys())
|
||||
|
||||
def get_option_by_id(self, opt_id):
|
||||
return self._registered_options.get(opt_id, None)
|
||||
|
||||
def get_option_by_name(self, name):
|
||||
for option in self._registered_options.values():
|
||||
if name == option.option_name:
|
||||
return option
|
||||
return None
|
||||
|
||||
def register_option(self, option):
|
||||
if option in self.options:
|
||||
# Re-registering the exact same option does nothing.
|
||||
return
|
||||
|
||||
if option.option_id in self._registered_options:
|
||||
raise ValueError(_('Option %(option_id)s already defined in '
|
||||
'%(registry)s.') %
|
||||
{'option_id': option.option_id,
|
||||
'registry': self._registry_type})
|
||||
if option.option_name in self.option_names:
|
||||
raise ValueError(_('Option %(option_name)s already defined in '
|
||||
'%(registry)s') %
|
||||
{'option_name': option.option_name,
|
||||
'registry': self._registry_type})
|
||||
self._registered_options[option.option_id] = option
|
||||
|
||||
|
||||
class ResourceOption(object):
|
||||
|
||||
def __init__(self, option_id, option_name, validator=_validator):
|
||||
"""The base object to define the option(s) to be stored in the DB.
|
||||
|
||||
:param option_id: The ID of the option. This will be used to lookup
|
||||
the option value from the DB and should not be
|
||||
changed once defined as the values will no longer
|
||||
be correctly mapped to the keys in the user_ref when
|
||||
retrieving the data from the DB.
|
||||
:type option_id: str
|
||||
:param option_name: The name of the option. This value will be used
|
||||
to map the value from the user request on a
|
||||
resource update to the correct option id to be
|
||||
stored in the database. This value should not be
|
||||
changed once defined as it will change the
|
||||
resulting keys in the user_ref.
|
||||
:type option_name: str
|
||||
:param validator: A callable that raises TypeError if the value to be
|
||||
persisted is incorrect. A single argument of the
|
||||
value to be persisted will be passed to it. No return
|
||||
value is expected.
|
||||
:type validator: callable
|
||||
"""
|
||||
if not isinstance(option_id, six.string_types) and len(option_id) == 4:
|
||||
raise TypeError(_('`option_id` must be a string, got %r')
|
||||
% option_id)
|
||||
elif len(option_id) != 4:
|
||||
raise ValueError(_('`option_id` must be 4 characters in '
|
||||
|
||||
'length. Got %r') % option_id)
|
||||
if not isinstance(option_name, six.string_types):
|
||||
raise TypeError(_('`option_name` must be a string. '
|
||||
'Got %r') % option_name)
|
||||
|
||||
self._option_id = option_id
|
||||
self._option_name = option_name
|
||||
self.validator = validator
|
||||
|
||||
@property
|
||||
def option_name(self):
|
||||
# NOTE(notmorgan) Option IDs should never be set outside of definition
|
||||
# time.
|
||||
return self._option_name
|
||||
|
||||
@property
|
||||
def option_id(self):
|
||||
# NOTE(notmorgan) Option IDs should never be set outside of definition
|
||||
# time.
|
||||
return self._option_id
|
|
@ -0,0 +1,16 @@
|
|||
# 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.
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
# NOTE(notmorgan): This is a no-op, no data-migration needed.
|
||||
pass
|
|
@ -0,0 +1,16 @@
|
|||
# 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.
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
# NOTE(notmorgan): This is a no-op, no data-migration needed.
|
||||
pass
|
|
@ -0,0 +1,34 @@
|
|||
# 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
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
meta = sql.MetaData()
|
||||
meta.bind = migrate_engine
|
||||
user_table = sql.Table('user', meta, autoload=True)
|
||||
|
||||
user_option = sql.Table(
|
||||
'user_option',
|
||||
meta,
|
||||
sql.Column('user_id', sql.String(64), sql.ForeignKey(user_table.c.id,
|
||||
ondelete='CASCADE'), nullable=False, primary_key=True),
|
||||
sql.Column('option_id', sql.String(4), nullable=False,
|
||||
primary_key=True),
|
||||
sql.Column('option_value', ks_sql.JsonBlob, nullable=True),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8')
|
||||
|
||||
user_option.create(migrate_engine, checkfirst=True)
|
|
@ -0,0 +1,34 @@
|
|||
# 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 resource_options
|
||||
|
||||
|
||||
USER_OPTIONS_REGISTRY = resource_options.ResourceOptionRegistry('USER')
|
||||
USER_OPTIONS_LIST = [
|
||||
# NOTE(notmorgan): This placeholder options can be removed once more
|
||||
# options are populated. This forces iteration on possible options for
|
||||
# complete test purposes in unit/functional/gate tests outside of the
|
||||
# explicit test cases that test resource options. This option is never
|
||||
# expected to be set.
|
||||
resource_options.ResourceOption('_TST', '__PLACEHOLDER__'),
|
||||
]
|
||||
|
||||
|
||||
# NOTE(notmorgan): wrap this in a function for testing purposes.
|
||||
# This is called on import by design.
|
||||
def register_user_options():
|
||||
for opt in USER_OPTIONS_LIST:
|
||||
USER_OPTIONS_REGISTRY.register_option(opt)
|
||||
|
||||
|
||||
register_user_options()
|
|
@ -18,6 +18,7 @@ from oslo_db import api as oslo_db_api
|
|||
import sqlalchemy
|
||||
|
||||
from keystone.common import driver_hints
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
|
@ -125,6 +126,9 @@ class Identity(base.IdentityDriverBase):
|
|||
user_ref = model.User.from_dict(user)
|
||||
user_ref.created_at = datetime.datetime.utcnow()
|
||||
session.add(user_ref)
|
||||
# Set resource options passed on creation
|
||||
resource_options.resource_options_ref_to_mapper(
|
||||
user_ref, model.UserOption)
|
||||
return base.filter_user(user_ref.to_dict())
|
||||
|
||||
def _create_password_expires_query(self, session, query, hints):
|
||||
|
@ -188,6 +192,16 @@ class Identity(base.IdentityDriverBase):
|
|||
for attr in model.User.attributes:
|
||||
if attr not in model.User.readonly_attributes:
|
||||
setattr(user_ref, attr, getattr(new_user, attr))
|
||||
# Move the "_resource_options" attribute over to the real user_ref
|
||||
# so that resource_options.resource_options_ref_to_mapper can
|
||||
# handle the work.
|
||||
setattr(user_ref, '_resource_options',
|
||||
getattr(new_user, '_resource_options', {}))
|
||||
|
||||
# Move options into the proper attribute mapper construct
|
||||
resource_options.resource_options_ref_to_mapper(
|
||||
user_ref, model.UserOption)
|
||||
|
||||
user_ref.extra = new_user.extra
|
||||
return base.filter_user(
|
||||
user_ref.to_dict(include_extra_dict=True))
|
||||
|
|
|
@ -17,9 +17,12 @@ import datetime
|
|||
import sqlalchemy
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.orm import collections
|
||||
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
import keystone.conf
|
||||
from keystone.identity.backends import resource_options as iro
|
||||
|
||||
|
||||
CONF = keystone.conf.CONF
|
||||
|
@ -30,11 +33,19 @@ class User(sql.ModelBase, sql.DictBase):
|
|||
attributes = ['id', 'name', 'domain_id', 'password', 'enabled',
|
||||
'default_project_id', 'password_expires_at']
|
||||
readonly_attributes = ['id', 'password_expires_at']
|
||||
resource_options_registry = iro.USER_OPTIONS_REGISTRY
|
||||
id = sql.Column(sql.String(64), primary_key=True)
|
||||
domain_id = sql.Column(sql.String(64), nullable=False)
|
||||
_enabled = sql.Column('enabled', sql.Boolean)
|
||||
extra = sql.Column(sql.JsonBlob())
|
||||
default_project_id = sql.Column(sql.String(64))
|
||||
_resource_option_mapper = orm.relationship(
|
||||
'UserOption',
|
||||
single_parent=True,
|
||||
cascade='all,delete,delete-orphan',
|
||||
lazy='subquery',
|
||||
backref='user',
|
||||
collection_class=collections.attribute_mapped_collection('option_id'))
|
||||
local_user = orm.relationship('LocalUser', uselist=False,
|
||||
single_parent=True, lazy='subquery',
|
||||
cascade='all,delete-orphan', backref='user')
|
||||
|
@ -184,6 +195,9 @@ class User(sql.ModelBase, sql.DictBase):
|
|||
d = super(User, self).to_dict(include_extra_dict=include_extra_dict)
|
||||
if 'default_project_id' in d and d['default_project_id'] is None:
|
||||
del d['default_project_id']
|
||||
# NOTE(notmorgan): Eventually it may make sense to drop the empty
|
||||
# option dict creation to the superclass (if enough models use it)
|
||||
d['options'] = resource_options.ref_mapper_to_dict_options(self)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
|
@ -199,10 +213,19 @@ class User(sql.ModelBase, sql.DictBase):
|
|||
|
||||
"""
|
||||
new_dict = user_dict.copy()
|
||||
resource_options = {}
|
||||
options = new_dict.pop('options', {})
|
||||
password_expires_at_key = 'password_expires_at'
|
||||
if password_expires_at_key in user_dict:
|
||||
del new_dict[password_expires_at_key]
|
||||
return super(User, cls).from_dict(new_dict)
|
||||
for opt in cls.resource_options_registry.options:
|
||||
if opt.option_name in options:
|
||||
opt_value = options[opt.option_name]
|
||||
opt.validator(opt_value)
|
||||
resource_options[opt.option_id] = opt_value
|
||||
user_obj = super(User, cls).from_dict(new_dict)
|
||||
setattr(user_obj, '_resource_options', resource_options)
|
||||
return user_obj
|
||||
|
||||
|
||||
class LocalUser(sql.ModelBase, sql.DictBase):
|
||||
|
@ -303,3 +326,17 @@ class UserGroupMembership(sql.ModelBase, sql.DictBase):
|
|||
group_id = sql.Column(sql.String(64),
|
||||
sql.ForeignKey('group.id'),
|
||||
primary_key=True)
|
||||
|
||||
|
||||
class UserOption(sql.ModelBase):
|
||||
__tablename__ = 'user_option'
|
||||
user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id',
|
||||
ondelete='CASCADE'), nullable=False,
|
||||
primary_key=True)
|
||||
option_id = sql.Column(sql.String(4), nullable=False,
|
||||
primary_key=True)
|
||||
option_value = sql.Column(sql.JsonBlob, nullable=True)
|
||||
|
||||
def __init__(self, option_id, option_value):
|
||||
self.option_id = option_id
|
||||
self.option_value = option_value
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
# 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 keystone.common import resource_options
|
||||
from keystone.tests import unit
|
||||
|
||||
|
||||
class TestResourceOptionObjects(unit.BaseTestCase):
|
||||
def test_option_init_validation(self):
|
||||
# option_name must be a string
|
||||
self.assertRaises(TypeError,
|
||||
resource_options.ResourceOption, 'test', 1234)
|
||||
# option_id must be a string
|
||||
self.assertRaises(TypeError,
|
||||
resource_options.ResourceOption, 1234, 'testing')
|
||||
# option_id must be 4 characters
|
||||
self.assertRaises(ValueError,
|
||||
resource_options.ResourceOption,
|
||||
'testing',
|
||||
'testing')
|
||||
resource_options.ResourceOption('test', 'testing')
|
||||
|
||||
def test_duplicate_option_cases(self):
|
||||
option_id_str_valid = 'test'
|
||||
registry = resource_options.ResourceOptionRegistry(option_id_str_valid)
|
||||
option_name_unique = uuid.uuid4().hex
|
||||
|
||||
option = resource_options.ResourceOption(
|
||||
option_id_str_valid, option_name_unique)
|
||||
option_dup_id = resource_options.ResourceOption(
|
||||
option_id_str_valid, uuid.uuid4().hex)
|
||||
option_dup_name = resource_options.ResourceOption(
|
||||
uuid.uuid4().hex[:4], option_name_unique)
|
||||
|
||||
registry.register_option(option)
|
||||
|
||||
self.assertRaises(ValueError, registry.register_option, option_dup_id)
|
||||
self.assertRaises(ValueError, registry.register_option,
|
||||
option_dup_name)
|
||||
self.assertIs(1, len(registry.options))
|
||||
registry.register_option(option)
|
||||
self.assertIs(1, len(registry.options))
|
||||
|
||||
def test_registry(self):
|
||||
option = resource_options.ResourceOption(uuid.uuid4().hex[:4],
|
||||
uuid.uuid4().hex)
|
||||
option2 = resource_options.ResourceOption(uuid.uuid4().hex[:4],
|
||||
uuid.uuid4().hex)
|
||||
registry = resource_options.ResourceOptionRegistry('TEST')
|
||||
|
||||
registry.register_option(option)
|
||||
self.assertIn(option.option_name, registry.option_names)
|
||||
self.assertIs(1, len(registry.options))
|
||||
self.assertIn(option.option_id, registry.option_ids)
|
||||
registry.register_option(option2)
|
||||
self.assertIn(option2.option_name, registry.option_names)
|
||||
self.assertIs(2, len(registry.options))
|
||||
self.assertIn(option2.option_id, registry.option_ids)
|
||||
self.assertIs(option,
|
||||
registry.get_option_by_id(option.option_id))
|
||||
self.assertIs(option2,
|
||||
registry.get_option_by_id(option2.option_id))
|
||||
self.assertIs(option,
|
||||
registry.get_option_by_name(option.option_name))
|
||||
self.assertIs(option2,
|
||||
registry.get_option_by_name(option2.option_name))
|
|
@ -848,6 +848,24 @@ class TestCase(BaseTestCase):
|
|||
excName = str(expected_exception)
|
||||
raise self.failureException("%s not raised" % excName)
|
||||
|
||||
def assertUserDictEqual(self, expected, observed, message=''):
|
||||
"""Assert that a user dict is equal to another user dict.
|
||||
|
||||
User dictionaries have some variable values that should be ignored in
|
||||
the comparison. This method is a helper that strips those elements out
|
||||
when comparing the user dictionary. This normalized these differences
|
||||
that should not change the comparison.
|
||||
"""
|
||||
# NOTE(notmorgan): An empty option list is the same as no options being
|
||||
# specified in the user_ref. This removes options if it is empty in
|
||||
# observed if options is not specified in the expected value.
|
||||
if ('options' in observed and not observed['options'] and
|
||||
'options' not in expected):
|
||||
observed = observed.copy()
|
||||
del observed['options']
|
||||
|
||||
self.assertDictEqual(expected, observed, message)
|
||||
|
||||
@property
|
||||
def ipv6_enabled(self):
|
||||
if socket.has_ipv6:
|
||||
|
|
|
@ -111,7 +111,8 @@ class IdentityDriverTests(object):
|
|||
'password': uuid.uuid4().hex,
|
||||
'enabled': True,
|
||||
'default_project_id': uuid.uuid4().hex,
|
||||
'password_expires_at': None
|
||||
'password_expires_at': None,
|
||||
'options': {}
|
||||
}
|
||||
if self.driver.is_domain_aware():
|
||||
user['domain_id'] = uuid.uuid4().hex
|
||||
|
|
|
@ -21,7 +21,7 @@ class ShadowUsersCoreTests(object):
|
|||
self.federated_user['unique_id'],
|
||||
self.federated_user['display_name'])
|
||||
self.assertIsNotNone(user['id'])
|
||||
self.assertEqual(5, len(user.keys()))
|
||||
self.assertEqual(6, len(user.keys()))
|
||||
self.assertIsNotNone(user['name'])
|
||||
self.assertIsNone(user['password_expires_at'])
|
||||
self.assertIsNotNone(user['domain_id'])
|
||||
|
|
|
@ -16,11 +16,13 @@ import uuid
|
|||
import freezegun
|
||||
|
||||
from keystone.common import controller
|
||||
from keystone.common import resource_options
|
||||
from keystone.common import sql
|
||||
from keystone.common import utils
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
from keystone.identity.backends import base
|
||||
from keystone.identity.backends import resource_options as iro
|
||||
from keystone.identity.backends import sql_model as model
|
||||
from keystone.tests.unit import test_backend_sql
|
||||
|
||||
|
@ -28,6 +30,180 @@ from keystone.tests.unit import test_backend_sql
|
|||
CONF = keystone.conf.CONF
|
||||
|
||||
|
||||
class UserResourceOptionTests(test_backend_sql.SqlTests):
|
||||
def setUp(self):
|
||||
super(UserResourceOptionTests, self).setUp()
|
||||
# RESET STATE OF REGISTRY OPTIONS
|
||||
self.addCleanup(iro.register_user_options)
|
||||
self.addCleanup(iro.USER_OPTIONS_REGISTRY._registered_options.clear)
|
||||
|
||||
self.option1 = resource_options.ResourceOption('opt1', 'option1')
|
||||
self.option2 = resource_options.ResourceOption('opt2', 'option2')
|
||||
self.cleanup_instance('option1', 'option2')
|
||||
|
||||
iro.USER_OPTIONS_REGISTRY._registered_options.clear()
|
||||
iro.USER_OPTIONS_REGISTRY.register_option(self.option1)
|
||||
iro.USER_OPTIONS_REGISTRY.register_option(self.option2)
|
||||
|
||||
def test_user_set_option_in_resource_option(self):
|
||||
user = self._create_user(self._get_user_dict())
|
||||
opt_value = uuid.uuid4().hex
|
||||
user['options'][self.option1.option_name] = opt_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
raw_ref = self._get_user_ref(user['id'])
|
||||
self.assertIn(self.option1.option_id, raw_ref._resource_option_mapper)
|
||||
self.assertEqual(
|
||||
opt_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option1.option_id].option_value)
|
||||
api_get_ref = self.identity_api.get_user(user['id'])
|
||||
# Ensure options are properly set in a .get_user call.
|
||||
self.assertEqual(opt_value,
|
||||
api_get_ref['options'][self.option1.option_name])
|
||||
|
||||
def test_user_add_update_delete_option_in_resource_option(self):
|
||||
user = self._create_user(self._get_user_dict())
|
||||
|
||||
opt_value = uuid.uuid4().hex
|
||||
new_opt_value = uuid.uuid4().hex
|
||||
|
||||
# Update user to add the new value option
|
||||
user['options'][self.option1.option_name] = opt_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
|
||||
# Update the option Value and confirm it is updated
|
||||
user['options'][self.option1.option_name] = new_opt_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(new_opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
|
||||
# Set the option value to None, meaning delete the option
|
||||
user['options'][self.option1.option_name] = None
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertNotIn(self.option1.option_name, new_ref['options'])
|
||||
|
||||
def test_user_add_delete_resource_option_existing_option_values(self):
|
||||
user = self._create_user(self._get_user_dict())
|
||||
|
||||
opt_value = uuid.uuid4().hex
|
||||
opt2_value = uuid.uuid4().hex
|
||||
|
||||
# Update user to add the new value option
|
||||
user['options'][self.option1.option_name] = opt_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
|
||||
# Update the option value for option 2 and confirm it is updated and
|
||||
# option1's value remains the same. Option 1 is not specified in the
|
||||
# updated user ref.
|
||||
del user['options'][self.option1.option_name]
|
||||
user['options'][self.option2.option_name] = opt2_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
self.assertEqual(opt2_value,
|
||||
new_ref['options'][self.option2.option_name])
|
||||
raw_ref = self._get_user_ref(user['id'])
|
||||
self.assertEqual(
|
||||
opt_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option1.option_id].option_value)
|
||||
self.assertEqual(
|
||||
opt2_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option2.option_id].option_value)
|
||||
|
||||
# Set the option value to None, meaning delete the option, ensure
|
||||
# option 2 still remains and has the right value
|
||||
user['options'][self.option1.option_name] = None
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertNotIn(self.option1.option_name, new_ref['options'])
|
||||
self.assertEqual(opt2_value,
|
||||
new_ref['options'][self.option2.option_name])
|
||||
raw_ref = self._get_user_ref(user['id'])
|
||||
self.assertNotIn(raw_ref._resource_option_mapper,
|
||||
self.option1.option_id)
|
||||
self.assertEqual(
|
||||
opt2_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option2.option_id].option_value)
|
||||
|
||||
def test_unregistered_resource_option_deleted(self):
|
||||
user = self._create_user(self._get_user_dict())
|
||||
|
||||
opt_value = uuid.uuid4().hex
|
||||
opt2_value = uuid.uuid4().hex
|
||||
|
||||
# Update user to add the new value option
|
||||
user['options'][self.option1.option_name] = opt_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
|
||||
# Update the option value for option 2 and confirm it is updated and
|
||||
# option1's value remains the same. Option 1 is not specified in the
|
||||
# updated user ref.
|
||||
del user['options'][self.option1.option_name]
|
||||
user['options'][self.option2.option_name] = opt2_value
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
self.assertEqual(opt2_value,
|
||||
new_ref['options'][self.option2.option_name])
|
||||
raw_ref = self._get_user_ref(user['id'])
|
||||
self.assertEqual(
|
||||
opt_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option1.option_id].option_value)
|
||||
self.assertEqual(
|
||||
opt2_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option2.option_id].option_value)
|
||||
|
||||
# clear registered options and only re-register option1, update user
|
||||
# and confirm option2 is gone from the ref and returned dict
|
||||
iro.USER_OPTIONS_REGISTRY._registered_options.clear()
|
||||
iro.USER_OPTIONS_REGISTRY.register_option(self.option1)
|
||||
user['name'] = uuid.uuid4().hex
|
||||
new_ref = self.identity_api.update_user(user['id'], user)
|
||||
self.assertNotIn(self.option2.option_name, new_ref['options'])
|
||||
self.assertEqual(opt_value,
|
||||
new_ref['options'][self.option1.option_name])
|
||||
raw_ref = self._get_user_ref(user['id'])
|
||||
self.assertNotIn(raw_ref._resource_option_mapper,
|
||||
self.option2.option_id)
|
||||
self.assertEqual(
|
||||
opt_value,
|
||||
raw_ref._resource_option_mapper[
|
||||
self.option1.option_id].option_value)
|
||||
|
||||
def _get_user_ref(self, user_id):
|
||||
with sql.session_for_read() as session:
|
||||
return session.query(model.User).get(user_id)
|
||||
|
||||
def _create_user(self, user_dict):
|
||||
user_dict['id'] = uuid.uuid4().hex
|
||||
user_dict = utils.hash_user_password(user_dict)
|
||||
with sql.session_for_write() as session:
|
||||
user_ref = model.User.from_dict(user_dict)
|
||||
session.add(user_ref)
|
||||
return base.filter_user(user_ref.to_dict())
|
||||
|
||||
def _get_user_dict(self):
|
||||
user = {
|
||||
'name': uuid.uuid4().hex,
|
||||
'domain_id': CONF.identity.default_domain_id,
|
||||
'enabled': True,
|
||||
'password': uuid.uuid4().hex
|
||||
}
|
||||
return user
|
||||
|
||||
|
||||
class DisableInactiveUserTests(test_backend_sql.SqlTests):
|
||||
def setUp(self):
|
||||
super(DisableInactiveUserTests, self).setUp()
|
||||
|
|
|
@ -64,7 +64,7 @@ class IdentityTests(object):
|
|||
# not be returned by the api
|
||||
self.user_sna.pop('password')
|
||||
self.user_sna['enabled'] = True
|
||||
self.assertDictEqual(self.user_sna, user_ref)
|
||||
self.assertUserDictEqual(self.user_sna, user_ref)
|
||||
|
||||
def test_authenticate_and_get_roles_no_metadata(self):
|
||||
user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id)
|
||||
|
|
|
@ -181,7 +181,7 @@ class LdapPoolCommonTestMixin(object):
|
|||
|
||||
self.user_sna.pop('password')
|
||||
self.user_sna['enabled'] = True
|
||||
self.assertDictEqual(self.user_sna, user_ref)
|
||||
self.assertUserDictEqual(self.user_sna, user_ref)
|
||||
|
||||
new_password = 'new_password'
|
||||
user_ref['password'] = new_password
|
||||
|
@ -195,7 +195,7 @@ class LdapPoolCommonTestMixin(object):
|
|||
password=new_password)
|
||||
|
||||
user_ref.pop('password')
|
||||
self.assertDictEqual(user_ref, user_ref2)
|
||||
self.assertUserDictEqual(user_ref, user_ref2)
|
||||
|
||||
# Authentication with old password would not work here as there
|
||||
# is only one connection in pool which get bind again with updated
|
||||
|
|
|
@ -2213,6 +2213,19 @@ class FullMigration(SqlMigrateBase, unit.TestCase):
|
|||
user_table = sqlalchemy.Table('user', self.metadata, autoload=True)
|
||||
self.assertFalse(user_table.c.domain_id.nullable)
|
||||
|
||||
def test_migration_016_add_user_options(self):
|
||||
self.expand(15)
|
||||
self.migrate(15)
|
||||
self.contract(15)
|
||||
|
||||
user_option = 'user_option'
|
||||
self.assertTableDoesNotExist(user_option)
|
||||
self.expand(16)
|
||||
self.migrate(16)
|
||||
self.contract(16)
|
||||
self.assertTableColumns(user_option,
|
||||
['user_id', 'option_id', 'option_value'])
|
||||
|
||||
|
||||
class MySQLOpportunisticFullMigration(FullMigration):
|
||||
FIXTURE = test_base.MySQLOpportunisticFixture
|
||||
|
|
Loading…
Reference in New Issue