Add parent_id field to projects

Field to point to a parent project. This enables
the possibility to use hierarchical projects.

Co-Authored-By: Rodrigo Duarte <rodrigods@lsd.ufcg.edu.br>
Co-Authored-By: Raildo Mascena <raildo@lsd.ufcg.edu.br>
Co-Authored-By: Telles Mota Vidal Nobrega <tellesmvn@lsd.ufcg.edu.br>

Implements: blueprint hierarchical-multitenancy
Change-Id: I2b56a362df40b664e3a802d09ca027277678b300
This commit is contained in:
Rodrigo Duarte Sousa
2014-08-04 17:09:57 -03:00
parent 6f806bdc9b
commit d81f23a6c3
8 changed files with 150 additions and 19 deletions

View File

@@ -51,11 +51,28 @@ class Assignment(assignment.Driver):
self.project = ProjectApi(CONF)
self.role = RoleApi(CONF, self.user)
def _set_default_parent_project(self, ref):
"""If the parent project ID has not been set, set it to None."""
if isinstance(ref, dict):
if 'parent_id' not in ref:
ref = dict(ref, parent_id=None)
return ref
elif isinstance(ref, list):
return [self._set_default_parent_project(x) for x in ref]
else:
raise ValueError(_('Expected dict or list: %s') % type(ref))
def _set_default_attributes(self, project_ref):
project_ref = self._set_default_domain(project_ref)
return self._set_default_parent_project(project_ref)
def get_project(self, tenant_id):
return self._set_default_domain(self.project.get(tenant_id))
return self._set_default_attributes(
self.project.get(tenant_id))
def list_projects(self, hints):
return self._set_default_domain(self.project.get_all())
return self._set_default_attributes(
self.project.get_all())
def list_projects_in_domain(self, domain_id):
# We don't support multiple domains within this driver, so ignore
@@ -64,7 +81,8 @@ class Assignment(assignment.Driver):
def get_project_by_name(self, tenant_name, domain_id):
self._validate_default_domain_id(domain_id)
return self._set_default_domain(self.project.get_by_name(tenant_name))
return self._set_default_attributes(
self.project.get_by_name(tenant_name))
def create_project(self, tenant_id, tenant):
self.project.check_allow_create()
@@ -75,14 +93,16 @@ class Assignment(assignment.Driver):
data['id'] = str(uuid.uuid4().hex)
if 'description' in data and data['description'] in ['', None]:
data.pop('description')
return self._set_default_domain(self.project.create(data))
return self._set_default_attributes(
self.project.create(data))
def update_project(self, tenant_id, tenant):
self.project.check_allow_update()
tenant = self._validate_default_domain(tenant)
if 'name' in tenant:
tenant['name'] = clean.project_name(tenant['name'])
return self._set_default_domain(self.project.update(tenant_id, tenant))
return self._set_default_attributes(
self.project.update(tenant_id, tenant))
def get_group_project_roles(self, groups, project_id, project_domain_id):
self.get_project(project_id)
@@ -153,7 +173,7 @@ class Assignment(assignment.Driver):
# Since the LDAP backend doesn't store the domain_id in the LDAP
# records (and only supports the default domain), we fill in the
# domain_id before we return the list.
return [self._set_default_domain(x) for x in
return [self._set_default_attributes(x) for x in
self.project.get_user_projects(user_dn, associations)]
def get_roles_for_groups(self, group_ids, project_id=None, domain_id=None):

View File

@@ -607,7 +607,8 @@ class Domain(sql.ModelBase, sql.DictBase):
class Project(sql.ModelBase, sql.DictBase):
__tablename__ = 'project'
attributes = ['id', 'name', 'domain_id', 'description', 'enabled']
attributes = ['id', 'name', 'domain_id', 'description', 'enabled',
'parent_id']
id = sql.Column(sql.String(64), primary_key=True)
name = sql.Column(sql.String(64), nullable=False)
domain_id = sql.Column(sql.String(64), sql.ForeignKey('domain.id'),
@@ -615,6 +616,7 @@ class Project(sql.ModelBase, sql.DictBase):
description = sql.Column(sql.Text())
enabled = sql.Column(sql.Boolean)
extra = sql.Column(sql.JsonBlob())
parent_id = sql.Column(sql.String(64), sql.ForeignKey('project.id'))
# Unique constraint across two columns to create the separation
# rather than just only 'name' being unique
__table_args__ = (sql.UniqueConstraint('domain_id', 'name'), {})

View File

@@ -0,0 +1,53 @@
# 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.sql import migration_helpers
_PROJECT_TABLE_NAME = 'project'
_PARENT_ID_COLUMN_NAME = 'parent_id'
def list_constraints(project_table):
constraints = [{'table': project_table,
'fk_column': _PARENT_ID_COLUMN_NAME,
'ref_column': project_table.c.id}]
return constraints
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
project_table = sql.Table(_PROJECT_TABLE_NAME, meta, autoload=True)
parent_id = sql.Column(_PARENT_ID_COLUMN_NAME, sql.String(64),
nullable=True)
project_table.create_column(parent_id)
if migrate_engine.name == 'sqlite':
return
migration_helpers.add_constraints(list_constraints(project_table))
def downgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
# SQLite does not support constraints, and querying the constraints
# raises an exception
if migrate_engine.name != 'sqlite':
migration_helpers.remove_constraints(list_constraints(meta))
project_table = sql.Table(_PROJECT_TABLE_NAME, meta, autoload=True)
project_table.drop_column(_PARENT_ID_COLUMN_NAME)

View File

@@ -24,24 +24,28 @@ TENANTS = [
'domain_id': DEFAULT_DOMAIN_ID,
'description': 'description',
'enabled': True,
'parent_id': None,
}, {
'id': 'baz',
'name': 'BAZ',
'domain_id': DEFAULT_DOMAIN_ID,
'description': 'description',
'enabled': True,
'parent_id': None,
}, {
'id': 'mtu',
'name': 'MTU',
'description': 'description',
'enabled': True,
'domain_id': DEFAULT_DOMAIN_ID
'domain_id': DEFAULT_DOMAIN_ID,
'parent_id': None,
}, {
'id': 'service',
'name': 'service',
'description': 'description',
'enabled': True,
'domain_id': DEFAULT_DOMAIN_ID
'domain_id': DEFAULT_DOMAIN_ID,
'parent_id': None,
}
]

View File

@@ -2507,7 +2507,8 @@ class IdentityTests(object):
project = {'id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'domain_id': DEFAULT_DOMAIN_ID,
'enabled': True}
'enabled': True,
'parent_id': None}
self.assignment_api.create_project(project['id'], project)
# Add a description attribute.
@@ -2522,7 +2523,8 @@ class IdentityTests(object):
project = {'id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'domain_id': DEFAULT_DOMAIN_ID,
'enabled': True}
'enabled': True,
'parent_id': None}
self.assignment_api.create_project(project['id'], project)
# Add a description attribute.

View File

@@ -1471,10 +1471,12 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase):
# NOTE(topol): LDAP implementation does not currently support the
# updating of a project name so this method override
# provides a different update test
project = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex,
project = {'id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'domain_id': CONF.identity.default_domain_id,
'description': uuid.uuid4().hex, 'enabled': True
}
'description': uuid.uuid4().hex,
'enabled': True,
'parent_id': None}
self.assignment_api.create_project(project['id'], project)
project_ref = self.assignment_api.get_project(project['id'])
@@ -1812,7 +1814,8 @@ class LDAPIdentityEnabledEmulation(LDAPIdentity):
'id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'domain_id': CONF.identity.default_domain_id,
'description': uuid.uuid4().hex}
'description': uuid.uuid4().hex,
'parent_id': None}
self.assignment_api.create_project(project['id'], project)
project_ref = self.assignment_api.get_project(project['id'])
@@ -2426,10 +2429,12 @@ class MultiLDAPandSQLIdentity(BaseLDAPIdentity, tests.SQLDriverOverrides,
def test_delete_domain_with_user_added(self):
domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex,
'enabled': True}
project = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex,
project = {'id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'domain_id': domain['id'],
'description': uuid.uuid4().hex, 'enabled': True
}
'description': uuid.uuid4().hex,
'parent_id': None,
'enabled': True}
self.assignment_api.create_domain(domain['id'], domain)
self.assignment_api.create_project(project['id'], project)
project_ref = self.assignment_api.get_project(project['id'])

View File

@@ -1462,6 +1462,50 @@ class SqlUpgradeTests(SqlMigrateBase):
index_data = [(idx.name, idx.columns.keys()) for idx in table.indexes]
self.assertNotIn(('ix_actor_id', ['actor_id']), index_data)
def test_project_parent_id_upgrade(self):
self.upgrade(61)
self.assertTableColumns('project',
['id', 'name', 'extra', 'description',
'enabled', 'domain_id', 'parent_id'])
def test_project_parent_id_downgrade(self):
self.upgrade(61)
self.downgrade(60)
self.assertTableColumns('project',
['id', 'name', 'extra', 'description',
'enabled', 'domain_id'])
def test_project_parent_id_cleanup(self):
# make sure that the parent_id field is dropped in the downgrade
self.upgrade(61)
session = self.Session()
beta = {
'id': uuid.uuid4().hex,
'description': uuid.uuid4().hex,
'domain_id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'parent_id': uuid.uuid4().hex
}
acme = {
'id': uuid.uuid4().hex,
'description': uuid.uuid4().hex,
'domain_id': uuid.uuid4().hex,
'name': uuid.uuid4().hex,
'parent_id': None
}
self.insert_dict(session, 'project', beta)
self.insert_dict(session, 'project', acme)
proj_table = sqlalchemy.Table('project', self.metadata, autoload=True)
self.assertEqual(2, session.query(proj_table).count())
session.close()
self.downgrade(60)
session = self.Session()
self.metadata.clear()
proj_table = sqlalchemy.Table('project', self.metadata, autoload=True)
self.assertEqual(2, session.query(proj_table).count())
project = session.query(proj_table)[0]
self.assertRaises(AttributeError, getattr, project, 'parent_id')
def populate_user_table(self, with_pass_enab=False,
with_pass_enab_domain=False):
# Populate the appropriate fields in the user

View File

@@ -298,9 +298,10 @@ class RestfulTestCase(tests.SQLDriverOverrides, rest.RestfulTestCase,
ref = self.new_ref()
return ref
def new_project_ref(self, domain_id):
def new_project_ref(self, domain_id, parent_id=None):
ref = self.new_ref()
ref['domain_id'] = domain_id
ref['parent_id'] = parent_id
return ref
def new_user_ref(self, domain_id, project_id=None):