Shadow users - Separate user identities

"Shadow users: unified identity" implementation:
Separated user identities from their locally managed credentials by
refactoring the user table into user, local_user, and password tables.

user -> local_user -> password
     -> federated_user
     -> ...

(identity) -> (credentials)

Migrated data from the user table to the local_user and password tables.
Modify backend code to utilize the new tables.

Note: #2 "Shadow LDAP and federated users" will be completed in a
different patch. The federated_user table will be added with that patch.

bp shadow-users

Change-Id: I0b6c188824e856d788fe7156e4a9dc2a04cdb6f8
This commit is contained in:
Ronald De Rose 2016-02-10 19:04:15 +00:00
parent 19b058574e
commit 312a041862
5 changed files with 315 additions and 130 deletions

View File

@ -0,0 +1,42 @@
# 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
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
user = sql.Table('user', meta, autoload=True)
local_user = sql.Table(
'local_user',
meta,
sql.Column('id', sql.Integer, primary_key=True, nullable=False),
sql.Column('user_id', sql.String(64),
sql.ForeignKey(user.c.id, ondelete='CASCADE'),
nullable=False, unique=True),
sql.Column('domain_id', sql.String(64), nullable=False),
sql.Column('name', sql.String(255), nullable=False),
sql.UniqueConstraint('domain_id', 'name'))
local_user.create(migrate_engine, checkfirst=True)
password = sql.Table(
'password',
meta,
sql.Column('id', sql.Integer, primary_key=True, nullable=False),
sql.Column('local_user_id', sql.Integer,
sql.ForeignKey(local_user.c.id, ondelete='CASCADE'),
nullable=False),
sql.Column('password', sql.String(128), nullable=False))
password.create(migrate_engine, checkfirst=True)

View File

@ -0,0 +1,57 @@
# 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 migrate
import sqlalchemy as sql
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
user_table = sql.Table('user', meta, autoload=True)
local_user_table = sql.Table('local_user', meta, autoload=True)
password_table = sql.Table('password', meta, autoload=True)
# migrate data to local_user table
local_user_values = []
for row in user_table.select().execute():
local_user_values.append({'user_id': row['id'],
'domain_id': row['domain_id'],
'name': row['name']})
if local_user_values:
local_user_table.insert().values(local_user_values).execute()
# migrate data to password table
sel = (
sql.select([user_table, local_user_table], use_labels=True)
.select_from(user_table.join(local_user_table, user_table.c.id ==
local_user_table.c.user_id))
)
user_rows = sel.execute()
password_values = []
for row in user_rows:
password_values.append({'local_user_id': row['local_user_id'],
'password': row['user_password']})
if password_values:
password_table.insert().values(password_values).execute()
# remove domain_id and name unique constraint
if migrate_engine.name != 'sqlite':
migrate.UniqueConstraint(user_table.c.domain_id,
user_table.c.name,
name='ixu_user_name_domain_id').drop()
# drop user columns
user_table.c.domain_id.drop()
user_table.c.name.drop()
user_table.c.password.drop()

View File

@ -12,6 +12,10 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
from sqlalchemy import and_
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import orm
from keystone.common import driver_hints from keystone.common import driver_hints
from keystone.common import sql from keystone.common import sql
from keystone.common import utils from keystone.common import utils
@ -25,15 +29,68 @@ class User(sql.ModelBase, sql.DictBase):
attributes = ['id', 'name', 'domain_id', 'password', 'enabled', attributes = ['id', 'name', 'domain_id', 'password', 'enabled',
'default_project_id'] 'default_project_id']
id = sql.Column(sql.String(64), primary_key=True) id = sql.Column(sql.String(64), primary_key=True)
name = sql.Column(sql.String(255), nullable=False)
domain_id = sql.Column(sql.String(64), nullable=False)
password = sql.Column(sql.String(128))
enabled = sql.Column(sql.Boolean) enabled = sql.Column(sql.Boolean)
extra = sql.Column(sql.JsonBlob()) extra = sql.Column(sql.JsonBlob())
default_project_id = sql.Column(sql.String(64)) default_project_id = sql.Column(sql.String(64))
# Unique constraint across two columns to create the separation local_user = orm.relationship('LocalUser', uselist=False,
# rather than just only 'name' being unique single_parent=True,
__table_args__ = (sql.UniqueConstraint('domain_id', 'name'),) cascade='all,delete-orphan', backref='user')
# name property
@hybrid_property
def name(self):
if self.local_user:
return self.local_user.name
else:
return None
@name.setter
def name(self, value):
if not self.local_user:
self.local_user = LocalUser()
self.local_user.name = value
@name.expression
def name(cls):
return LocalUser.name
# password property
@hybrid_property
def password(self):
if self.local_user and self.local_user.passwords:
return self.local_user.passwords[0].password
else:
return None
@password.setter
def password(self, value):
if not self.local_user:
self.local_user = LocalUser()
if not self.local_user.passwords:
self.local_user.passwords.append(Password())
self.local_user.passwords[0].password = value
@password.expression
def password(cls):
return Password.password
# domain_id property
@hybrid_property
def domain_id(self):
if self.local_user:
return self.local_user.domain_id
else:
return None
@domain_id.setter
def domain_id(self, value):
if not self.local_user:
self.local_user = LocalUser()
self.local_user.domain_id = value
@domain_id.expression
def domain_id(cls):
return LocalUser.domain_id
def to_dict(self, include_extra_dict=False): def to_dict(self, include_extra_dict=False):
d = super(User, self).to_dict(include_extra_dict=include_extra_dict) d = super(User, self).to_dict(include_extra_dict=include_extra_dict)
@ -42,6 +99,29 @@ class User(sql.ModelBase, sql.DictBase):
return d return d
class LocalUser(sql.ModelBase, sql.DictBase):
__tablename__ = 'local_user'
attributes = ['id', 'user_id', 'domain_id', 'name']
id = sql.Column(sql.Integer, primary_key=True)
user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id',
ondelete='CASCADE'), unique=True)
domain_id = sql.Column(sql.String(64), nullable=False)
name = sql.Column(sql.String(255), nullable=False)
passwords = orm.relationship('Password', single_parent=True,
cascade='all,delete-orphan',
backref='local_user')
__table_args__ = (sql.UniqueConstraint('domain_id', 'name'), {})
class Password(sql.ModelBase, sql.DictBase):
__tablename__ = 'password'
attributes = ['id', 'local_user_id', 'password']
id = sql.Column(sql.Integer, primary_key=True)
local_user_id = sql.Column(sql.Integer, sql.ForeignKey('local_user.id',
ondelete='CASCADE'))
password = sql.Column(sql.String(128))
class Group(sql.ModelBase, sql.DictBase): class Group(sql.ModelBase, sql.DictBase):
__tablename__ = 'group' __tablename__ = 'group'
attributes = ['id', 'name', 'domain_id', 'description'] attributes = ['id', 'name', 'domain_id', 'description']
@ -118,7 +198,7 @@ class Identity(identity.IdentityDriverV8):
@driver_hints.truncated @driver_hints.truncated
def list_users(self, hints): def list_users(self, hints):
session = sql.get_session() session = sql.get_session()
query = session.query(User) query = session.query(User).outerjoin(LocalUser)
user_refs = sql.filter_limit_query(User, query, hints) user_refs = sql.filter_limit_query(User, query, hints)
return [identity.filter_user(x.to_dict()) for x in user_refs] return [identity.filter_user(x.to_dict()) for x in user_refs]
@ -134,9 +214,9 @@ class Identity(identity.IdentityDriverV8):
def get_user_by_name(self, user_name, domain_id): def get_user_by_name(self, user_name, domain_id):
session = sql.get_session() session = sql.get_session()
query = session.query(User) query = session.query(User).join(LocalUser)
query = query.filter_by(name=user_name) query = query.filter(and_(LocalUser.name == user_name,
query = query.filter_by(domain_id=domain_id) LocalUser.domain_id == domain_id))
try: try:
user_ref = query.one() user_ref = query.one()
except sql.NotFound: except sql.NotFound:
@ -219,7 +299,8 @@ class Identity(identity.IdentityDriverV8):
def list_users_in_group(self, group_id, hints): def list_users_in_group(self, group_id, hints):
session = sql.get_session() session = sql.get_session()
self.get_group(group_id) self.get_group(group_id)
query = session.query(User).join(UserGroupMembership) query = session.query(User).outerjoin(LocalUser)
query = query.join(UserGroupMembership)
query = query.filter(UserGroupMembership.group_id == group_id) query = query.filter(UserGroupMembership.group_id == group_id)
query = sql.filter_limit_query(User, query, hints) query = sql.filter_limit_query(User, query, hints)
return [identity.filter_user(u.to_dict()) for u in query] return [identity.filter_user(u.to_dict()) for u in query]

View File

@ -125,14 +125,24 @@ class SqlModels(SqlTests):
def test_user_model(self): def test_user_model(self):
cols = (('id', sql.String, 64), cols = (('id', sql.String, 64),
('name', sql.String, 255),
('password', sql.String, 128),
('domain_id', sql.String, 64),
('default_project_id', sql.String, 64), ('default_project_id', sql.String, 64),
('enabled', sql.Boolean, None), ('enabled', sql.Boolean, None),
('extra', sql.JsonBlob, None)) ('extra', sql.JsonBlob, None))
self.assertExpectedSchema('user', cols) self.assertExpectedSchema('user', cols)
def test_local_user_model(self):
cols = (('id', sql.Integer, None),
('user_id', sql.String, 64),
('name', sql.String, 255),
('domain_id', sql.String, 64))
self.assertExpectedSchema('local_user', cols)
def test_password_model(self):
cols = (('id', sql.Integer, None),
('local_user_id', sql.Integer, None),
('password', sql.String, 128))
self.assertExpectedSchema('password', cols)
def test_group_model(self): def test_group_model(self):
cols = (('id', sql.String, 64), cols = (('id', sql.String, 64),
('name', sql.String, 64), ('name', sql.String, 64),

View File

@ -29,7 +29,6 @@ WARNING::
all data will be lost. all data will be lost.
""" """
import copy
import json import json
import uuid import uuid
@ -225,6 +224,23 @@ class SqlMigrateBase(unit.SQLDriverOverrides, unit.TestCase):
else: else:
raise AssertionError('Table "%s" already exists' % table_name) raise AssertionError('Table "%s" already exists' % table_name)
def assertTableCountsMatch(self, table1_name, table2_name):
try:
table1 = self.select_table(table1_name)
except sqlalchemy.exc.NoSuchTableError:
raise AssertionError('Table "%s" does not exist' % table1_name)
try:
table2 = self.select_table(table2_name)
except sqlalchemy.exc.NoSuchTableError:
raise AssertionError('Table "%s" does not exist' % table2_name)
session = self.Session()
table1_count = session.execute(table1.count()).scalar()
table2_count = session.execute(table2.count()).scalar()
if table1_count != table2_count:
raise AssertionError('Table counts do not match: {0} ({1}), {2} '
'({3})'.format(table1_name, table1_count,
table2_name, table2_count))
def upgrade(self, *args, **kwargs): def upgrade(self, *args, **kwargs):
self._migrate(*args, **kwargs) self._migrate(*args, **kwargs)
@ -700,126 +716,105 @@ class SqlUpgradeTests(SqlMigrateBase):
session.close() session.close()
def populate_user_table(self, with_pass_enab=False, def test_add_local_user_and_password_tables(self):
with_pass_enab_domain=False): local_user_table = 'local_user'
# Populate the appropriate fields in the user password_table = 'password'
# table, depending on the parameters: self.upgrade(89)
# self.assertTableDoesNotExist(local_user_table)
# Default: id, name, extra self.assertTableDoesNotExist(password_table)
# pass_enab: Add password, enabled as well self.upgrade(90)
# pass_enab_domain: Add password, enabled and domain as well self.assertTableColumns(local_user_table,
# ['id',
this_table = sqlalchemy.Table("user", 'user_id',
self.metadata, 'domain_id',
autoload=True) 'name'])
for user in default_fixtures.USERS: self.assertTableColumns(password_table,
extra = copy.deepcopy(user) ['id',
extra.pop('id') 'local_user_id',
extra.pop('name') 'password'])
if with_pass_enab: def test_migrate_data_to_local_user_and_password_tables(self):
password = extra.pop('password', None) def get_expected_users():
enabled = extra.pop('enabled', True) expected_users = []
ins = this_table.insert().values( for test_user in default_fixtures.USERS:
user = {}
user['id'] = uuid.uuid4().hex
user['name'] = test_user['name']
user['domain_id'] = test_user['domain_id']
user['password'] = test_user['password']
user['enabled'] = True
user['extra'] = json.dumps(uuid.uuid4().hex)
user['default_project_id'] = uuid.uuid4().hex
expected_users.append(user)
return expected_users
def add_users_to_db(expected_users, user_table):
for user in expected_users:
ins = user_table.insert().values(
{'id': user['id'], {'id': user['id'],
'name': user['name'], 'name': user['name'],
'password': password, 'domain_id': user['domain_id'],
'enabled': bool(enabled), 'password': user['password'],
'extra': json.dumps(extra)}) 'enabled': user['enabled'],
else: 'extra': user['extra'],
if with_pass_enab_domain: 'default_project_id': user['default_project_id']})
password = extra.pop('password', None) ins.execute()
enabled = extra.pop('enabled', True)
extra.pop('domain_id')
ins = this_table.insert().values(
{'id': user['id'],
'name': user['name'],
'domain_id': user['domain_id'],
'password': password,
'enabled': bool(enabled),
'extra': json.dumps(extra)})
else:
ins = this_table.insert().values(
{'id': user['id'],
'name': user['name'],
'extra': json.dumps(extra)})
self.engine.execute(ins)
def populate_tenant_table(self, with_desc_enab=False, def get_users_from_db(user_table, local_user_table, password_table):
with_desc_enab_domain=False): sel = (
# Populate the appropriate fields in the tenant or sqlalchemy.select([user_table.c.id,
# project table, depending on the parameters user_table.c.enabled,
# user_table.c.extra,
# Default: id, name, extra user_table.c.default_project_id,
# desc_enab: Add description, enabled as well local_user_table.c.name,
# desc_enab_domain: Add description, enabled and domain as well, local_user_table.c.domain_id,
# plus use project instead of tenant password_table.c.password])
# .select_from(user_table.join(local_user_table,
if with_desc_enab_domain: user_table.c.id ==
# By this time tenants are now projects local_user_table.c.user_id)
this_table = sqlalchemy.Table("project", .join(password_table,
self.metadata, local_user_table.c.id ==
password_table.c.local_user_id))
)
user_rows = sel.execute()
users = []
for row in user_rows:
users.append(
{'id': row['id'],
'name': row['name'],
'domain_id': row['domain_id'],
'password': row['password'],
'enabled': row['enabled'],
'extra': row['extra'],
'default_project_id': row['default_project_id']})
return users
meta = sqlalchemy.MetaData()
meta.bind = self.engine
user_table_name = 'user'
local_user_table_name = 'local_user'
password_table_name = 'password'
# populate current user table
self.upgrade(90)
user_table = sqlalchemy.Table(user_table_name, meta, autoload=True)
expected_users = get_expected_users()
add_users_to_db(expected_users, user_table)
# upgrade to migration and test
self.upgrade(91)
self.assertTableCountsMatch(user_table_name, local_user_table_name)
self.assertTableCountsMatch(local_user_table_name, password_table_name)
meta.clear()
user_table = sqlalchemy.Table(user_table_name, meta, autoload=True)
local_user_table = sqlalchemy.Table(local_user_table_name, meta,
autoload=True)
password_table = sqlalchemy.Table(password_table_name, meta,
autoload=True) autoload=True)
else: actual_users = get_users_from_db(user_table, local_user_table,
this_table = sqlalchemy.Table("tenant", password_table)
self.metadata, self.assertListEqual(expected_users, actual_users)
autoload=True)
for tenant in default_fixtures.TENANTS:
extra = copy.deepcopy(tenant)
extra.pop('id')
extra.pop('name')
if with_desc_enab:
desc = extra.pop('description', None)
enabled = extra.pop('enabled', True)
ins = this_table.insert().values(
{'id': tenant['id'],
'name': tenant['name'],
'description': desc,
'enabled': bool(enabled),
'extra': json.dumps(extra)})
else:
if with_desc_enab_domain:
desc = extra.pop('description', None)
enabled = extra.pop('enabled', True)
extra.pop('domain_id')
ins = this_table.insert().values(
{'id': tenant['id'],
'name': tenant['name'],
'domain_id': tenant['domain_id'],
'description': desc,
'enabled': bool(enabled),
'extra': json.dumps(extra)})
else:
ins = this_table.insert().values(
{'id': tenant['id'],
'name': tenant['name'],
'extra': json.dumps(extra)})
self.engine.execute(ins)
def _mysql_check_all_tables_innodb(self):
database = self.engine.url.database
connection = self.engine.connect()
# sanity check
total = connection.execute("SELECT count(*) "
"from information_schema.TABLES "
"where TABLE_SCHEMA='%(database)s'" %
dict(database=database))
self.assertTrue(total.scalar() > 0, "No tables found. Wrong schema?")
noninnodb = connection.execute("SELECT table_name "
"from information_schema.TABLES "
"where TABLE_SCHEMA='%(database)s' "
"and ENGINE!='InnoDB' "
"and TABLE_NAME!='migrate_version'" %
dict(database=database))
names = [x[0] for x in noninnodb]
self.assertEqual([], names,
"Non-InnoDB tables exist")
connection.close()
class VersionTests(SqlMigrateBase): class VersionTests(SqlMigrateBase):