Browse Source

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
Ronald De Rose 3 years ago
parent
commit
312a041862

+ 42
- 0
keystone/common/sql/migrate_repo/versions/090_add_local_user_and_password_tables.py View File

@@ -0,0 +1,42 @@
1
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+# not use this file except in compliance with the License. You may obtain
3
+# a copy of the License at
4
+#
5
+#      http://www.apache.org/licenses/LICENSE-2.0
6
+#
7
+# Unless required by applicable law or agreed to in writing, software
8
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+# License for the specific language governing permissions and limitations
11
+# under the License.
12
+
13
+import sqlalchemy as sql
14
+
15
+
16
+def upgrade(migrate_engine):
17
+    meta = sql.MetaData()
18
+    meta.bind = migrate_engine
19
+
20
+    user = sql.Table('user', meta, autoload=True)
21
+
22
+    local_user = sql.Table(
23
+        'local_user',
24
+        meta,
25
+        sql.Column('id', sql.Integer, primary_key=True, nullable=False),
26
+        sql.Column('user_id', sql.String(64),
27
+                   sql.ForeignKey(user.c.id, ondelete='CASCADE'),
28
+                   nullable=False, unique=True),
29
+        sql.Column('domain_id', sql.String(64), nullable=False),
30
+        sql.Column('name', sql.String(255), nullable=False),
31
+        sql.UniqueConstraint('domain_id', 'name'))
32
+    local_user.create(migrate_engine, checkfirst=True)
33
+
34
+    password = sql.Table(
35
+        'password',
36
+        meta,
37
+        sql.Column('id', sql.Integer, primary_key=True, nullable=False),
38
+        sql.Column('local_user_id', sql.Integer,
39
+                   sql.ForeignKey(local_user.c.id, ondelete='CASCADE'),
40
+                   nullable=False),
41
+        sql.Column('password', sql.String(128), nullable=False))
42
+    password.create(migrate_engine, checkfirst=True)

+ 57
- 0
keystone/common/sql/migrate_repo/versions/091_migrate_data_to_local_user_and_password_tables.py View File

@@ -0,0 +1,57 @@
1
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+# not use this file except in compliance with the License. You may obtain
3
+# a copy of the License at
4
+#
5
+#      http://www.apache.org/licenses/LICENSE-2.0
6
+#
7
+# Unless required by applicable law or agreed to in writing, software
8
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+# License for the specific language governing permissions and limitations
11
+# under the License.
12
+
13
+import migrate
14
+import sqlalchemy as sql
15
+
16
+
17
+def upgrade(migrate_engine):
18
+    meta = sql.MetaData()
19
+    meta.bind = migrate_engine
20
+
21
+    user_table = sql.Table('user', meta, autoload=True)
22
+    local_user_table = sql.Table('local_user', meta, autoload=True)
23
+    password_table = sql.Table('password', meta, autoload=True)
24
+
25
+    # migrate data to local_user table
26
+    local_user_values = []
27
+    for row in user_table.select().execute():
28
+        local_user_values.append({'user_id': row['id'],
29
+                                  'domain_id': row['domain_id'],
30
+                                  'name': row['name']})
31
+    if local_user_values:
32
+        local_user_table.insert().values(local_user_values).execute()
33
+
34
+    # migrate data to password table
35
+    sel = (
36
+        sql.select([user_table, local_user_table], use_labels=True)
37
+           .select_from(user_table.join(local_user_table, user_table.c.id ==
38
+                                        local_user_table.c.user_id))
39
+    )
40
+    user_rows = sel.execute()
41
+    password_values = []
42
+    for row in user_rows:
43
+        password_values.append({'local_user_id': row['local_user_id'],
44
+                                'password': row['user_password']})
45
+    if password_values:
46
+        password_table.insert().values(password_values).execute()
47
+
48
+    # remove domain_id and name unique constraint
49
+    if migrate_engine.name != 'sqlite':
50
+        migrate.UniqueConstraint(user_table.c.domain_id,
51
+                                 user_table.c.name,
52
+                                 name='ixu_user_name_domain_id').drop()
53
+
54
+    # drop user columns
55
+    user_table.c.domain_id.drop()
56
+    user_table.c.name.drop()
57
+    user_table.c.password.drop()

+ 92
- 11
keystone/identity/backends/sql.py View File

@@ -12,6 +12,10 @@
12 12
 # License for the specific language governing permissions and limitations
13 13
 # under the License.
14 14
 
15
+from sqlalchemy import and_
16
+from sqlalchemy.ext.hybrid import hybrid_property
17
+from sqlalchemy import orm
18
+
15 19
 from keystone.common import driver_hints
16 20
 from keystone.common import sql
17 21
 from keystone.common import utils
@@ -25,15 +29,68 @@ class User(sql.ModelBase, sql.DictBase):
25 29
     attributes = ['id', 'name', 'domain_id', 'password', 'enabled',
26 30
                   'default_project_id']
27 31
     id = sql.Column(sql.String(64), primary_key=True)
28
-    name = sql.Column(sql.String(255), nullable=False)
29
-    domain_id = sql.Column(sql.String(64), nullable=False)
30
-    password = sql.Column(sql.String(128))
31 32
     enabled = sql.Column(sql.Boolean)
32 33
     extra = sql.Column(sql.JsonBlob())
33 34
     default_project_id = sql.Column(sql.String(64))
34
-    # Unique constraint across two columns to create the separation
35
-    # rather than just only 'name' being unique
36
-    __table_args__ = (sql.UniqueConstraint('domain_id', 'name'),)
35
+    local_user = orm.relationship('LocalUser', uselist=False,
36
+                                  single_parent=True,
37
+                                  cascade='all,delete-orphan', backref='user')
38
+
39
+    # name property
40
+    @hybrid_property
41
+    def name(self):
42
+        if self.local_user:
43
+            return self.local_user.name
44
+        else:
45
+            return None
46
+
47
+    @name.setter
48
+    def name(self, value):
49
+        if not self.local_user:
50
+            self.local_user = LocalUser()
51
+        self.local_user.name = value
52
+
53
+    @name.expression
54
+    def name(cls):
55
+        return LocalUser.name
56
+
57
+    # password property
58
+    @hybrid_property
59
+    def password(self):
60
+        if self.local_user and self.local_user.passwords:
61
+            return self.local_user.passwords[0].password
62
+        else:
63
+            return None
64
+
65
+    @password.setter
66
+    def password(self, value):
67
+        if not self.local_user:
68
+            self.local_user = LocalUser()
69
+        if not self.local_user.passwords:
70
+            self.local_user.passwords.append(Password())
71
+        self.local_user.passwords[0].password = value
72
+
73
+    @password.expression
74
+    def password(cls):
75
+        return Password.password
76
+
77
+    # domain_id property
78
+    @hybrid_property
79
+    def domain_id(self):
80
+        if self.local_user:
81
+            return self.local_user.domain_id
82
+        else:
83
+            return None
84
+
85
+    @domain_id.setter
86
+    def domain_id(self, value):
87
+        if not self.local_user:
88
+            self.local_user = LocalUser()
89
+        self.local_user.domain_id = value
90
+
91
+    @domain_id.expression
92
+    def domain_id(cls):
93
+        return LocalUser.domain_id
37 94
 
38 95
     def to_dict(self, include_extra_dict=False):
39 96
         d = super(User, self).to_dict(include_extra_dict=include_extra_dict)
@@ -42,6 +99,29 @@ class User(sql.ModelBase, sql.DictBase):
42 99
         return d
43 100
 
44 101
 
102
+class LocalUser(sql.ModelBase, sql.DictBase):
103
+    __tablename__ = 'local_user'
104
+    attributes = ['id', 'user_id', 'domain_id', 'name']
105
+    id = sql.Column(sql.Integer, primary_key=True)
106
+    user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id',
107
+                         ondelete='CASCADE'), unique=True)
108
+    domain_id = sql.Column(sql.String(64), nullable=False)
109
+    name = sql.Column(sql.String(255), nullable=False)
110
+    passwords = orm.relationship('Password', single_parent=True,
111
+                                 cascade='all,delete-orphan',
112
+                                 backref='local_user')
113
+    __table_args__ = (sql.UniqueConstraint('domain_id', 'name'), {})
114
+
115
+
116
+class Password(sql.ModelBase, sql.DictBase):
117
+    __tablename__ = 'password'
118
+    attributes = ['id', 'local_user_id', 'password']
119
+    id = sql.Column(sql.Integer, primary_key=True)
120
+    local_user_id = sql.Column(sql.Integer, sql.ForeignKey('local_user.id',
121
+                               ondelete='CASCADE'))
122
+    password = sql.Column(sql.String(128))
123
+
124
+
45 125
 class Group(sql.ModelBase, sql.DictBase):
46 126
     __tablename__ = 'group'
47 127
     attributes = ['id', 'name', 'domain_id', 'description']
@@ -118,7 +198,7 @@ class Identity(identity.IdentityDriverV8):
118 198
     @driver_hints.truncated
119 199
     def list_users(self, hints):
120 200
         session = sql.get_session()
121
-        query = session.query(User)
201
+        query = session.query(User).outerjoin(LocalUser)
122 202
         user_refs = sql.filter_limit_query(User, query, hints)
123 203
         return [identity.filter_user(x.to_dict()) for x in user_refs]
124 204
 
@@ -134,9 +214,9 @@ class Identity(identity.IdentityDriverV8):
134 214
 
135 215
     def get_user_by_name(self, user_name, domain_id):
136 216
         session = sql.get_session()
137
-        query = session.query(User)
138
-        query = query.filter_by(name=user_name)
139
-        query = query.filter_by(domain_id=domain_id)
217
+        query = session.query(User).join(LocalUser)
218
+        query = query.filter(and_(LocalUser.name == user_name,
219
+                                  LocalUser.domain_id == domain_id))
140 220
         try:
141 221
             user_ref = query.one()
142 222
         except sql.NotFound:
@@ -219,7 +299,8 @@ class Identity(identity.IdentityDriverV8):
219 299
     def list_users_in_group(self, group_id, hints):
220 300
         session = sql.get_session()
221 301
         self.get_group(group_id)
222
-        query = session.query(User).join(UserGroupMembership)
302
+        query = session.query(User).outerjoin(LocalUser)
303
+        query = query.join(UserGroupMembership)
223 304
         query = query.filter(UserGroupMembership.group_id == group_id)
224 305
         query = sql.filter_limit_query(User, query, hints)
225 306
         return [identity.filter_user(u.to_dict()) for u in query]

+ 13
- 3
keystone/tests/unit/test_backend_sql.py View File

@@ -125,14 +125,24 @@ class SqlModels(SqlTests):
125 125
 
126 126
     def test_user_model(self):
127 127
         cols = (('id', sql.String, 64),
128
-                ('name', sql.String, 255),
129
-                ('password', sql.String, 128),
130
-                ('domain_id', sql.String, 64),
131 128
                 ('default_project_id', sql.String, 64),
132 129
                 ('enabled', sql.Boolean, None),
133 130
                 ('extra', sql.JsonBlob, None))
134 131
         self.assertExpectedSchema('user', cols)
135 132
 
133
+    def test_local_user_model(self):
134
+        cols = (('id', sql.Integer, None),
135
+                ('user_id', sql.String, 64),
136
+                ('name', sql.String, 255),
137
+                ('domain_id', sql.String, 64))
138
+        self.assertExpectedSchema('local_user', cols)
139
+
140
+    def test_password_model(self):
141
+        cols = (('id', sql.Integer, None),
142
+                ('local_user_id', sql.Integer, None),
143
+                ('password', sql.String, 128))
144
+        self.assertExpectedSchema('password', cols)
145
+
136 146
     def test_group_model(self):
137 147
         cols = (('id', sql.String, 64),
138 148
                 ('name', sql.String, 64),

+ 113
- 118
keystone/tests/unit/test_sql_upgrade.py View File

@@ -29,7 +29,6 @@ WARNING::
29 29
     all data will be lost.
30 30
 """
31 31
 
32
-import copy
33 32
 import json
34 33
 import uuid
35 34
 
@@ -225,6 +224,23 @@ class SqlMigrateBase(unit.SQLDriverOverrides, unit.TestCase):
225 224
         else:
226 225
             raise AssertionError('Table "%s" already exists' % table_name)
227 226
 
227
+    def assertTableCountsMatch(self, table1_name, table2_name):
228
+        try:
229
+            table1 = self.select_table(table1_name)
230
+        except sqlalchemy.exc.NoSuchTableError:
231
+            raise AssertionError('Table "%s" does not exist' % table1_name)
232
+        try:
233
+            table2 = self.select_table(table2_name)
234
+        except sqlalchemy.exc.NoSuchTableError:
235
+            raise AssertionError('Table "%s" does not exist' % table2_name)
236
+        session = self.Session()
237
+        table1_count = session.execute(table1.count()).scalar()
238
+        table2_count = session.execute(table2.count()).scalar()
239
+        if table1_count != table2_count:
240
+            raise AssertionError('Table counts do not match: {0} ({1}), {2} '
241
+                                 '({3})'.format(table1_name, table1_count,
242
+                                                table2_name, table2_count))
243
+
228 244
     def upgrade(self, *args, **kwargs):
229 245
         self._migrate(*args, **kwargs)
230 246
 
@@ -700,126 +716,105 @@ class SqlUpgradeTests(SqlMigrateBase):
700 716
 
701 717
         session.close()
702 718
 
703
-    def populate_user_table(self, with_pass_enab=False,
704
-                            with_pass_enab_domain=False):
705
-        # Populate the appropriate fields in the user
706
-        # table, depending on the parameters:
707
-        #
708
-        # Default: id, name, extra
709
-        # pass_enab: Add password, enabled as well
710
-        # pass_enab_domain: Add password, enabled and domain as well
711
-        #
712
-        this_table = sqlalchemy.Table("user",
713
-                                      self.metadata,
714
-                                      autoload=True)
715
-        for user in default_fixtures.USERS:
716
-            extra = copy.deepcopy(user)
717
-            extra.pop('id')
718
-            extra.pop('name')
719
-
720
-            if with_pass_enab:
721
-                password = extra.pop('password', None)
722
-                enabled = extra.pop('enabled', True)
723
-                ins = this_table.insert().values(
719
+    def test_add_local_user_and_password_tables(self):
720
+        local_user_table = 'local_user'
721
+        password_table = 'password'
722
+        self.upgrade(89)
723
+        self.assertTableDoesNotExist(local_user_table)
724
+        self.assertTableDoesNotExist(password_table)
725
+        self.upgrade(90)
726
+        self.assertTableColumns(local_user_table,
727
+                                ['id',
728
+                                 'user_id',
729
+                                 'domain_id',
730
+                                 'name'])
731
+        self.assertTableColumns(password_table,
732
+                                ['id',
733
+                                 'local_user_id',
734
+                                 'password'])
735
+
736
+    def test_migrate_data_to_local_user_and_password_tables(self):
737
+        def get_expected_users():
738
+            expected_users = []
739
+            for test_user in default_fixtures.USERS:
740
+                user = {}
741
+                user['id'] = uuid.uuid4().hex
742
+                user['name'] = test_user['name']
743
+                user['domain_id'] = test_user['domain_id']
744
+                user['password'] = test_user['password']
745
+                user['enabled'] = True
746
+                user['extra'] = json.dumps(uuid.uuid4().hex)
747
+                user['default_project_id'] = uuid.uuid4().hex
748
+                expected_users.append(user)
749
+            return expected_users
750
+
751
+        def add_users_to_db(expected_users, user_table):
752
+            for user in expected_users:
753
+                ins = user_table.insert().values(
724 754
                     {'id': user['id'],
725 755
                      'name': user['name'],
726
-                     'password': password,
727
-                     'enabled': bool(enabled),
728
-                     'extra': json.dumps(extra)})
729
-            else:
730
-                if with_pass_enab_domain:
731
-                    password = extra.pop('password', None)
732
-                    enabled = extra.pop('enabled', True)
733
-                    extra.pop('domain_id')
734
-                    ins = this_table.insert().values(
735
-                        {'id': user['id'],
736
-                         'name': user['name'],
737
-                         'domain_id': user['domain_id'],
738
-                         'password': password,
739
-                         'enabled': bool(enabled),
740
-                         'extra': json.dumps(extra)})
741
-                else:
742
-                    ins = this_table.insert().values(
743
-                        {'id': user['id'],
744
-                         'name': user['name'],
745
-                         'extra': json.dumps(extra)})
746
-            self.engine.execute(ins)
747
-
748
-    def populate_tenant_table(self, with_desc_enab=False,
749
-                              with_desc_enab_domain=False):
750
-        # Populate the appropriate fields in the tenant or
751
-        # project table, depending on the parameters
752
-        #
753
-        # Default: id, name, extra
754
-        # desc_enab: Add description, enabled as well
755
-        # desc_enab_domain: Add description, enabled and domain as well,
756
-        #                   plus use project instead of tenant
757
-        #
758
-        if with_desc_enab_domain:
759
-            # By this time tenants are now projects
760
-            this_table = sqlalchemy.Table("project",
761
-                                          self.metadata,
762
-                                          autoload=True)
763
-        else:
764
-            this_table = sqlalchemy.Table("tenant",
765
-                                          self.metadata,
766
-                                          autoload=True)
756
+                     'domain_id': user['domain_id'],
757
+                     'password': user['password'],
758
+                     'enabled': user['enabled'],
759
+                     'extra': user['extra'],
760
+                     'default_project_id': user['default_project_id']})
761
+                ins.execute()
762
+
763
+        def get_users_from_db(user_table, local_user_table, password_table):
764
+            sel = (
765
+                sqlalchemy.select([user_table.c.id,
766
+                                   user_table.c.enabled,
767
+                                   user_table.c.extra,
768
+                                   user_table.c.default_project_id,
769
+                                   local_user_table.c.name,
770
+                                   local_user_table.c.domain_id,
771
+                                   password_table.c.password])
772
+                .select_from(user_table.join(local_user_table,
773
+                                             user_table.c.id ==
774
+                                             local_user_table.c.user_id)
775
+                                       .join(password_table,
776
+                                             local_user_table.c.id ==
777
+                                             password_table.c.local_user_id))
778
+            )
779
+            user_rows = sel.execute()
780
+            users = []
781
+            for row in user_rows:
782
+                users.append(
783
+                    {'id': row['id'],
784
+                     'name': row['name'],
785
+                     'domain_id': row['domain_id'],
786
+                     'password': row['password'],
787
+                     'enabled': row['enabled'],
788
+                     'extra': row['extra'],
789
+                     'default_project_id': row['default_project_id']})
790
+            return users
791
+
792
+        meta = sqlalchemy.MetaData()
793
+        meta.bind = self.engine
767 794
 
768
-        for tenant in default_fixtures.TENANTS:
769
-            extra = copy.deepcopy(tenant)
770
-            extra.pop('id')
771
-            extra.pop('name')
772
-
773
-            if with_desc_enab:
774
-                desc = extra.pop('description', None)
775
-                enabled = extra.pop('enabled', True)
776
-                ins = this_table.insert().values(
777
-                    {'id': tenant['id'],
778
-                     'name': tenant['name'],
779
-                     'description': desc,
780
-                     'enabled': bool(enabled),
781
-                     'extra': json.dumps(extra)})
782
-            else:
783
-                if with_desc_enab_domain:
784
-                    desc = extra.pop('description', None)
785
-                    enabled = extra.pop('enabled', True)
786
-                    extra.pop('domain_id')
787
-                    ins = this_table.insert().values(
788
-                        {'id': tenant['id'],
789
-                         'name': tenant['name'],
790
-                         'domain_id': tenant['domain_id'],
791
-                         'description': desc,
792
-                         'enabled': bool(enabled),
793
-                         'extra': json.dumps(extra)})
794
-                else:
795
-                    ins = this_table.insert().values(
796
-                        {'id': tenant['id'],
797
-                         'name': tenant['name'],
798
-                         'extra': json.dumps(extra)})
799
-            self.engine.execute(ins)
800
-
801
-    def _mysql_check_all_tables_innodb(self):
802
-        database = self.engine.url.database
803
-
804
-        connection = self.engine.connect()
805
-        # sanity check
806
-        total = connection.execute("SELECT count(*) "
807
-                                   "from information_schema.TABLES "
808
-                                   "where TABLE_SCHEMA='%(database)s'" %
809
-                                   dict(database=database))
810
-        self.assertTrue(total.scalar() > 0, "No tables found. Wrong schema?")
811
-
812
-        noninnodb = connection.execute("SELECT table_name "
813
-                                       "from information_schema.TABLES "
814
-                                       "where TABLE_SCHEMA='%(database)s' "
815
-                                       "and ENGINE!='InnoDB' "
816
-                                       "and TABLE_NAME!='migrate_version'" %
817
-                                       dict(database=database))
818
-        names = [x[0] for x in noninnodb]
819
-        self.assertEqual([], names,
820
-                         "Non-InnoDB tables exist")
821
-
822
-        connection.close()
795
+        user_table_name = 'user'
796
+        local_user_table_name = 'local_user'
797
+        password_table_name = 'password'
798
+
799
+        # populate current user table
800
+        self.upgrade(90)
801
+        user_table = sqlalchemy.Table(user_table_name, meta, autoload=True)
802
+        expected_users = get_expected_users()
803
+        add_users_to_db(expected_users, user_table)
804
+
805
+        # upgrade to migration and test
806
+        self.upgrade(91)
807
+        self.assertTableCountsMatch(user_table_name, local_user_table_name)
808
+        self.assertTableCountsMatch(local_user_table_name, password_table_name)
809
+        meta.clear()
810
+        user_table = sqlalchemy.Table(user_table_name, meta, autoload=True)
811
+        local_user_table = sqlalchemy.Table(local_user_table_name, meta,
812
+                                            autoload=True)
813
+        password_table = sqlalchemy.Table(password_table_name, meta,
814
+                                          autoload=True)
815
+        actual_users = get_users_from_db(user_table, local_user_table,
816
+                                         password_table)
817
+        self.assertListEqual(expected_users, actual_users)
823 818
 
824 819
 
825 820
 class VersionTests(SqlMigrateBase):

Loading…
Cancel
Save