Require domain_id when registering Identity Providers

An Identity Provider (IdP) should be related (1:1) to a domain so that
federated users properly belong to a domain and can be uniquely
identified by their domain + unique_id. This patch makes it so that a
domain_id is required when registering a new IdP. If not explicitly set
via the API, the IdP will be mapped to a newly created domain. The docs
and release notes will be added in a subsequent patch.

Partial-Bug: #1642687
Partially-Implements: bp support-federated-attr
Change-Id: Id18b8b2fe853b97631bc990df8188ed64a6e1275
This commit is contained in:
Ronald De Rose 2016-11-18 16:41:08 +00:00
parent 8f038adac7
commit 8c190a1a29
10 changed files with 372 additions and 8 deletions

View File

@ -0,0 +1,38 @@
# 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 upgrades
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
idp_table = sql.Table('identity_provider', meta, autoload=True)
idp_table.c.domain_id.alter(nullable=False, unique=True)
if upgrades.USE_TRIGGERS:
if migrate_engine.name == 'postgresql':
drop_idp_insert_trigger = (
'DROP TRIGGER idp_insert_read_only on identity_provider;'
)
elif migrate_engine.name == 'mysql':
drop_idp_insert_trigger = (
'DROP TRIGGER idp_insert_read_only;'
)
else:
drop_idp_insert_trigger = (
'DROP TRIGGER IF EXISTS idp_insert_read_only;'
)
migrate_engine.execute(drop_idp_insert_trigger)

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 uuid
import sqlalchemy as sql
from sqlalchemy.orm import sessionmaker
from keystone.resource.backends import base
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
maker = sessionmaker(bind=migrate_engine)
session = maker()
idp_table = sql.Table('identity_provider', meta, autoload=True)
for idp_row in idp_table.select().execute():
domain_id = _create_federated_domain(meta, session, idp_row['id'])
# update idp with the new federated domain_id
values = {'domain_id': domain_id}
stmt = idp_table.update().where(
idp_table.c.id == idp_row['id']).values(values)
stmt.execute()
def _create_federated_domain(meta, session, idp_id):
domain_id = uuid.uuid4().hex
desc = 'Auto generated federated domain for Identity Provider: ' + idp_id
federated_domain = {
'id': domain_id,
'name': domain_id,
'enabled': True,
'description': desc,
'domain_id': base.NULL_DOMAIN_ID,
'is_domain': True,
'parent_id': None,
'extra': '{}'
}
project_table = sql.Table('project', meta, autoload=True)
new_row = project_table.insert().values(**federated_domain)
session.execute(new_row)
session.commit()
return domain_id

View File

@ -0,0 +1,73 @@
# 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 upgrades
MYSQL_INSERT_TRIGGER = """
CREATE TRIGGER idp_insert_read_only BEFORE INSERT ON identity_provider
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '%s';
END;
"""
SQLITE_INSERT_TRIGGER = """
CREATE TRIGGER idp_insert_read_only BEFORE INSERT ON identity_provider
BEGIN
SELECT RAISE (ABORT, '%s');
END;
"""
POSTGRESQL_INSERT_TRIGGER = """
CREATE OR REPLACE FUNCTION keystone_read_only_insert()
RETURNS trigger AS
$BODY$
BEGIN
RAISE EXCEPTION '%s';
END
$BODY$ LANGUAGE plpgsql;
CREATE TRIGGER idp_insert_read_only BEFORE INSERT ON identity_provider
FOR EACH ROW
EXECUTE PROCEDURE keystone_read_only_insert();
"""
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
idp = sql.Table('identity_provider', meta, autoload=True)
project = sql.Table('project', meta, autoload=True)
domain_id = sql.Column('domain_id', sql.String(64),
sql.ForeignKey(project.c.id), nullable=True)
idp.create_column(domain_id)
if upgrades.USE_TRIGGERS:
# Setting idp to be read-only to prevent old code from creating an idp
# without a domain_id during an upgrade. This should be okay as it is
# highly unlikely that an idp would be created during the migration and
# the impact from preventing creations is minor.
error_message = ('Identity provider migration in progress. Cannot '
'insert new rows into the identity_provider table at '
'this time.')
if migrate_engine.name == 'postgresql':
idp_insert_trigger = POSTGRESQL_INSERT_TRIGGER % error_message
elif migrate_engine.name == 'sqlite':
idp_insert_trigger = SQLITE_INSERT_TRIGGER % error_message
else:
idp_insert_trigger = MYSQL_INSERT_TRIGGER % error_message
migrate_engine.execute(idp_insert_trigger)

View File

@ -51,10 +51,11 @@ class FederationProtocolModel(sql.ModelBase, sql.DictBase):
class IdentityProviderModel(sql.ModelBase, sql.DictBase): class IdentityProviderModel(sql.ModelBase, sql.DictBase):
__tablename__ = 'identity_provider' __tablename__ = 'identity_provider'
attributes = ['id', 'enabled', 'description', 'remote_ids'] attributes = ['id', 'domain_id', 'enabled', 'description', 'remote_ids']
mutable_attributes = frozenset(['description', 'enabled', 'remote_ids']) mutable_attributes = frozenset(['description', 'enabled', 'remote_ids'])
id = sql.Column(sql.String(64), primary_key=True) id = sql.Column(sql.String(64), primary_key=True)
domain_id = sql.Column(sql.String(64), nullable=False, unique=True)
enabled = sql.Column(sql.Boolean, nullable=False) enabled = sql.Column(sql.Boolean, nullable=False)
description = sql.Column(sql.Text(), nullable=True) description = sql.Column(sql.Text(), nullable=True)
remote_ids = orm.relationship('IdPRemoteIdsModel', remote_ids = orm.relationship('IdPRemoteIdsModel',

View File

@ -56,7 +56,7 @@ class IdentityProvider(_ControllerBase):
member_name = 'identity_provider' member_name = 'identity_provider'
_public_parameters = frozenset(['id', 'enabled', 'description', _public_parameters = frozenset(['id', 'enabled', 'description',
'remote_ids', 'links' 'remote_ids', 'links', 'domain_id'
]) ])
@classmethod @classmethod

View File

@ -12,6 +12,8 @@
"""Main entry point into the Federation service.""" """Main entry point into the Federation service."""
import uuid
from keystone.common import cache from keystone.common import cache
from keystone.common import dependency from keystone.common import dependency
from keystone.common import extension from keystone.common import extension
@ -43,6 +45,7 @@ extension.register_public_extension(EXTENSION_DATA['alias'], EXTENSION_DATA)
@dependency.provider('federation_api') @dependency.provider('federation_api')
@dependency.requires('resource_api')
class Manager(manager.Manager): class Manager(manager.Manager):
"""Default pivot point for the Federation backend. """Default pivot point for the Federation backend.
@ -56,6 +59,29 @@ class Manager(manager.Manager):
def __init__(self): def __init__(self):
super(Manager, self).__init__(CONF.federation.driver) super(Manager, self).__init__(CONF.federation.driver)
def create_idp(self, idp_id, idp):
if not idp.get('domain_id'):
idp['domain_id'] = self._create_idp_domain(idp_id)
else:
self._assert_valid_domain_id(idp['domain_id'])
return self.driver.create_idp(idp_id, idp)
def _create_idp_domain(self, idp_id):
domain_id = uuid.uuid4().hex
desc = 'Auto generated federated domain for Identity Provider: '
desc += idp_id
domain = {
'id': domain_id,
'name': domain_id,
'description': desc,
'enabled': True
}
self.resource_api.create_domain(domain['id'], domain)
return domain_id
def _assert_valid_domain_id(self, domain_id):
self.resource_api.get_domain(domain_id)
@MEMOIZE @MEMOIZE
def get_enabled_service_providers(self): def get_enabled_service_providers(self):
"""List enabled service providers for Service Catalog. """List enabled service providers for Service Catalog.

View File

@ -78,7 +78,20 @@ service_provider_update = {
'additionalProperties': False 'additionalProperties': False
} }
_identity_provider_properties = { _identity_provider_properties_create = {
'enabled': parameter_types.boolean,
'description': validation.nullable(parameter_types.description),
'domain_id': validation.nullable(parameter_types.id_string),
'remote_ids': {
'type': ['array', 'null'],
'items': {
'type': 'string'
},
'uniqueItems': True
}
}
_identity_provider_properties_update = {
'enabled': parameter_types.boolean, 'enabled': parameter_types.boolean,
'description': validation.nullable(parameter_types.description), 'description': validation.nullable(parameter_types.description),
'remote_ids': { 'remote_ids': {
@ -92,13 +105,13 @@ _identity_provider_properties = {
identity_provider_create = { identity_provider_create = {
'type': 'object', 'type': 'object',
'properties': _identity_provider_properties, 'properties': _identity_provider_properties_create,
'additionalProperties': False 'additionalProperties': False
} }
identity_provider_update = { identity_provider_update = {
'type': 'object', 'type': 'object',
'properties': _identity_provider_properties, 'properties': _identity_provider_properties_update,
# Make sure at least one property is being updated # Make sure at least one property is being updated
'minProperties': 1, 'minProperties': 1,
'additionalProperties': False 'additionalProperties': False

View File

@ -21,6 +21,7 @@ class SqlFederation(test_backend_sql.SqlModels):
def test_identity_provider(self): def test_identity_provider(self):
cols = (('id', sql.String, 64), cols = (('id', sql.String, 64),
('domain_id', sql.String, 64),
('enabled', sql.Boolean, None), ('enabled', sql.Boolean, None),
('description', sql.Text, None)) ('description', sql.Text, None))
self.assertExpectedSchema('identity_provider', cols) self.assertExpectedSchema('identity_provider', cols)

View File

@ -48,6 +48,7 @@ from keystone.common import sql
from keystone.common.sql import upgrades from keystone.common.sql import upgrades
import keystone.conf import keystone.conf
from keystone.credential.providers import fernet as credential_fernet from keystone.credential.providers import fernet as credential_fernet
from keystone.resource.backends import base as resource_base
from keystone.tests import unit from keystone.tests import unit
from keystone.tests.unit import default_fixtures from keystone.tests.unit import default_fixtures
from keystone.tests.unit import ksfixtures from keystone.tests.unit import ksfixtures
@ -1837,6 +1838,72 @@ class FullMigration(SqlMigrateBase, unit.TestCase):
self.contract(11) self.contract(11)
self.assertTrue(self.does_unique_constraint_exist(table_name, column)) self.assertTrue(self.does_unique_constraint_exist(table_name, column))
def test_migration_012_add_domain_id_to_idp(self):
def _create_domain():
domain_id = uuid.uuid4().hex
domain = {
'id': domain_id,
'name': domain_id,
'enabled': True,
'description': uuid.uuid4().hex,
'domain_id': resource_base.NULL_DOMAIN_ID,
'is_domain': True,
'parent_id': None,
'extra': '{}'
}
self.insert_dict(session, 'project', domain)
return domain_id
def _get_new_idp(domain_id):
new_idp = {'id': uuid.uuid4().hex,
'domain_id': domain_id,
'enabled': True,
'description': uuid.uuid4().hex}
return new_idp
session = self.sessionmaker()
idp_name = 'identity_provider'
self.expand(11)
self.migrate(11)
self.contract(11)
self.assertTableColumns(idp_name,
['id',
'enabled',
'description'])
# add some data
for i in range(5):
idp = {'id': uuid.uuid4().hex,
'enabled': True,
'description': uuid.uuid4().hex}
self.insert_dict(session, idp_name, idp)
# upgrade
self.expand(12)
self.assertTableColumns(idp_name,
['id',
'domain_id',
'enabled',
'description'])
# confirm we cannot insert an idp during expand
domain_id = _create_domain()
new_idp = _get_new_idp(domain_id)
self.assertRaises(db_exception.DBError, self.insert_dict, session,
idp_name, new_idp)
# confirm we cannot insert an idp during migrate
self.migrate(12)
self.assertRaises(db_exception.DBError, self.insert_dict, session,
idp_name, new_idp)
# confirm we can insert a new idp after contract
self.contract(12)
self.insert_dict(session, idp_name, new_idp)
# confirm domain_id column is not null
idp_table = sqlalchemy.Table(idp_name, self.metadata, autoload=True)
self.assertFalse(idp_table.c.domain_id.nullable)
class MySQLOpportunisticFullMigration(FullMigration): class MySQLOpportunisticFullMigration(FullMigration):
FIXTURE = test_base.MySQLOpportunisticFixture FIXTURE = test_base.MySQLOpportunisticFixture

View File

@ -823,13 +823,14 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
resp = self.get(url) resp = self.get(url)
return resp return resp
def _create_default_idp(self, body=None): def _create_default_idp(self, body=None,
expected_status=http_client.CREATED):
"""Create default IdP.""" """Create default IdP."""
url = self.base_url(suffix=uuid.uuid4().hex) url = self.base_url(suffix=uuid.uuid4().hex)
if body is None: if body is None:
body = self._http_idp_input() body = self._http_idp_input()
resp = self.put(url, body={'identity_provider': body}, resp = self.put(url, body={'identity_provider': body},
expected_status=http_client.CREATED) expected_status=expected_status)
return resp return resp
def _http_idp_input(self, **kwargs): def _http_idp_input(self, **kwargs):
@ -877,7 +878,12 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
body={'mapping': mapping}, body={'mapping': mapping},
expected_status=http_client.CREATED) expected_status=http_client.CREATED)
def test_create_idp(self): def assertIdpDomainCreated(self, idp_id, domain_id):
domain = self.resource_api.get_domain(domain_id)
self.assertEqual(domain_id, domain['name'])
self.assertIn(idp_id, domain['description'])
def test_create_idp_without_domain_id(self):
"""Create the IdentityProvider entity associated to remote_ids.""" """Create the IdentityProvider entity associated to remote_ids."""
keys_to_check = list(self.idp_keys) keys_to_check = list(self.idp_keys)
body = self.default_body.copy() body = self.default_body.copy()
@ -886,6 +892,81 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
self.assertValidResponse(resp, 'identity_provider', dummy_validator, self.assertValidResponse(resp, 'identity_provider', dummy_validator,
keys_to_check=keys_to_check, keys_to_check=keys_to_check,
ref=body) ref=body)
attr = self._fetch_attribute_from_response(resp, 'identity_provider')
self.assertIdpDomainCreated(attr['id'], attr['domain_id'])
def test_create_idp_with_domain_id(self):
keys_to_check = list(self.idp_keys)
keys_to_check.append('domain_id')
body = self.default_body.copy()
body['description'] = uuid.uuid4().hex
domain = unit.new_domain_ref()
self.resource_api.create_domain(domain['id'], domain)
body['domain_id'] = domain['id']
resp = self._create_default_idp(body=body)
self.assertValidResponse(resp, 'identity_provider', dummy_validator,
keys_to_check=keys_to_check,
ref=body)
def test_create_idp_domain_id_none(self):
keys_to_check = list(self.idp_keys)
body = self.default_body.copy()
body['description'] = uuid.uuid4().hex
body['domain_id'] = None
resp = self._create_default_idp(body=body)
self.assertValidResponse(resp, 'identity_provider', dummy_validator,
keys_to_check=keys_to_check,
ref=body)
attr = self._fetch_attribute_from_response(resp, 'identity_provider')
self.assertIdpDomainCreated(attr['id'], attr['domain_id'])
def test_create_idp_domain_id_unique_constraint(self):
# create domain and add domain_id to keys to check
domain = unit.new_domain_ref()
self.resource_api.create_domain(domain['id'], domain)
keys_to_check = list(self.idp_keys)
keys_to_check.append('domain_id')
# create idp with the domain_id
body = self.default_body.copy()
body['description'] = uuid.uuid4().hex
body['domain_id'] = domain['id']
resp = self._create_default_idp(body=body)
self.assertValidResponse(resp, 'identity_provider', dummy_validator,
keys_to_check=keys_to_check,
ref=body)
# create a 2nd idp with the same domain_id
url = self.base_url(suffix=uuid.uuid4().hex)
body = self.default_body.copy()
body['description'] = uuid.uuid4().hex
body['domain_id'] = domain['id']
resp = self.put(url, body={'identity_provider': body},
expected_status=http_client.CONFLICT)
resp_data = jsonutils.loads(resp.body)
self.assertIn('Duplicate entry',
resp_data.get('error', {}).get('message'))
def test_cannot_update_idp_domain(self):
# create new idp
body = self.default_body.copy()
default_resp = self._create_default_idp(body=body)
default_idp = self._fetch_attribute_from_response(default_resp,
'identity_provider')
idp_id = default_idp.get('id')
self.assertIsNotNone(idp_id)
# create domain and try to update the idp's domain
domain = unit.new_domain_ref()
self.resource_api.create_domain(domain['id'], domain)
body['domain_id'] = domain['id']
body = {'identity_provider': body}
url = self.base_url(suffix=idp_id)
self.patch(url, body=body, expected_status=http_client.BAD_REQUEST)
def test_create_idp_with_nonexistent_domain_id_fails(self):
body = self.default_body.copy()
body['description'] = uuid.uuid4().hex
body['domain_id'] = uuid.uuid4().hex
self._create_default_idp(body=body,
expected_status=http_client.NOT_FOUND)
def test_create_idp_remote(self): def test_create_idp_remote(self):
"""Create the IdentityProvider entity associated to remote_ids.""" """Create the IdentityProvider entity associated to remote_ids."""
@ -900,6 +981,8 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
self.assertValidResponse(resp, 'identity_provider', dummy_validator, self.assertValidResponse(resp, 'identity_provider', dummy_validator,
keys_to_check=keys_to_check, keys_to_check=keys_to_check,
ref=body) ref=body)
attr = self._fetch_attribute_from_response(resp, 'identity_provider')
self.assertIdpDomainCreated(attr['id'], attr['domain_id'])
def test_create_idp_remote_repeated(self): def test_create_idp_remote_repeated(self):
"""Create two IdentityProvider entities with some remote_ids. """Create two IdentityProvider entities with some remote_ids.
@ -1060,6 +1143,7 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
ids = set(ids) ids = set(ids)
keys_to_check = self.idp_keys keys_to_check = self.idp_keys
keys_to_check.append('domain_id')
url = self.base_url() url = self.base_url()
resp = self.get(url) resp = self.get(url)
self.assertValidListResponse(resp, 'identity_providers', self.assertValidListResponse(resp, 'identity_providers',
@ -1130,6 +1214,9 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
""" """
url = self.base_url(suffix=uuid.uuid4().hex) url = self.base_url(suffix=uuid.uuid4().hex)
body = self._http_idp_input() body = self._http_idp_input()
domain = unit.new_domain_ref()
self.resource_api.create_domain(domain['id'], domain)
body['domain_id'] = domain['id']
self.put(url, body={'identity_provider': body}, self.put(url, body={'identity_provider': body},
expected_status=http_client.CREATED) expected_status=http_client.CREATED)
resp = self.put(url, body={'identity_provider': body}, resp = self.put(url, body={'identity_provider': body},
@ -1142,6 +1229,9 @@ class FederatedIdentityProviderTests(test_v3.RestfulTestCase):
def test_get_idp(self): def test_get_idp(self):
"""Create and later fetch IdP.""" """Create and later fetch IdP."""
body = self._http_idp_input() body = self._http_idp_input()
domain = unit.new_domain_ref()
self.resource_api.create_domain(domain['id'], domain)
body['domain_id'] = domain['id']
default_resp = self._create_default_idp(body=body) default_resp = self._create_default_idp(body=body)
default_idp = self._fetch_attribute_from_response(default_resp, default_idp = self._fetch_attribute_from_response(default_resp,
'identity_provider') 'identity_provider')