From ee54ba0ce49a5cebbf991e705492ad060e11867f Mon Sep 17 00:00:00 2001 From: Kristi Nikolla Date: Mon, 29 Jul 2019 16:19:51 -0400 Subject: [PATCH] Expiring User Group Membership Model Creates the model and migration for the expiring user group membership table. Change-Id: I48093403539918f81e6a174bdfa7b6497dd307fb Partial-Bug: 1809116 --- .../073_contract_expiring_group_membership.py | 15 ++++++ .../073_migrate_expiring_group_membership.py | 15 ++++++ .../073_expand_expiring_group_membership.py | 47 +++++++++++++++++++ keystone/conf/federation.py | 10 ++++ keystone/federation/backends/sql.py | 13 ++++- keystone/identity/backends/sql_model.py | 38 +++++++++++++++ .../tests/unit/test_backend_federation_sql.py | 3 +- keystone/tests/unit/test_sql_upgrade.py | 25 ++++++++++ 8 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 keystone/common/sql/contract_repo/versions/073_contract_expiring_group_membership.py create mode 100644 keystone/common/sql/data_migration_repo/versions/073_migrate_expiring_group_membership.py create mode 100644 keystone/common/sql/expand_repo/versions/073_expand_expiring_group_membership.py diff --git a/keystone/common/sql/contract_repo/versions/073_contract_expiring_group_membership.py b/keystone/common/sql/contract_repo/versions/073_contract_expiring_group_membership.py new file mode 100644 index 0000000000..8aa15c1ef2 --- /dev/null +++ b/keystone/common/sql/contract_repo/versions/073_contract_expiring_group_membership.py @@ -0,0 +1,15 @@ +# 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): + pass diff --git a/keystone/common/sql/data_migration_repo/versions/073_migrate_expiring_group_membership.py b/keystone/common/sql/data_migration_repo/versions/073_migrate_expiring_group_membership.py new file mode 100644 index 0000000000..8aa15c1ef2 --- /dev/null +++ b/keystone/common/sql/data_migration_repo/versions/073_migrate_expiring_group_membership.py @@ -0,0 +1,15 @@ +# 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): + pass diff --git a/keystone/common/sql/expand_repo/versions/073_expand_expiring_group_membership.py b/keystone/common/sql/expand_repo/versions/073_expand_expiring_group_membership.py new file mode 100644 index 0000000000..8577ee0522 --- /dev/null +++ b/keystone/common/sql/expand_repo/versions/073_expand_expiring_group_membership.py @@ -0,0 +1,47 @@ +# 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 + + identity_provider = sql.Table('identity_provider', meta, autoload=True) + authorization_ttl = sql.Column('authorization_ttl', sql.Integer, + nullable=True) + identity_provider.create_column(authorization_ttl) + + user_table = sql.Table('user', meta, autoload=True) + group_table = sql.Table('group', meta, autoload=True) + idp_table = sql.Table('identity_provider', meta, autoload=True) + + expiring_user_group_membership = sql.Table( + 'expiring_user_group_membership', meta, + + sql.Column('user_id', sql.String(64), + sql.ForeignKey(user_table.c.id), primary_key=True), + sql.Column('group_id', sql.String(64), + sql.ForeignKey(group_table.c.id), primary_key=True), + sql.Column('idp_id', + sql.String(64), + sql.ForeignKey(idp_table.c.id, + ondelete='CASCADE'), + primary_key=True), + sql.Column('last_verified', sql.DateTime(), nullable=False), + + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + expiring_user_group_membership.create(migrate_engine, checkfirst=True) diff --git a/keystone/conf/federation.py b/keystone/conf/federation.py index f38c04b4cc..f99aef9b52 100644 --- a/keystone/conf/federation.py +++ b/keystone/conf/federation.py @@ -94,6 +94,15 @@ enabled. There is typically no reason to disable this. """)) +default_authorization_ttl = cfg.IntOpt( + 'default_authorization_ttl', + default=0, + help=utils.fmt(""" +Default time in minutes for the validity of group memberships carried over +from a mapping. Default is 0, which means disabled. +""")) + + GROUP_NAME = __name__.split('.')[-1] ALL_OPTS = [ driver, @@ -103,6 +112,7 @@ ALL_OPTS = [ trusted_dashboard, sso_callback_template, caching, + default_authorization_ttl, ] diff --git a/keystone/federation/backends/sql.py b/keystone/federation/backends/sql.py index ca7a592d3a..7d39f69c3c 100644 --- a/keystone/federation/backends/sql.py +++ b/keystone/federation/backends/sql.py @@ -51,16 +51,25 @@ class FederationProtocolModel(sql.ModelBase, sql.ModelDictMixin): class IdentityProviderModel(sql.ModelBase, sql.ModelDictMixin): __tablename__ = 'identity_provider' - attributes = ['id', 'domain_id', 'enabled', 'description', 'remote_ids'] - mutable_attributes = frozenset(['description', 'enabled', 'remote_ids']) + attributes = ['id', 'domain_id', 'enabled', 'description', 'remote_ids', + 'authorization_ttl'] + mutable_attributes = frozenset(['description', 'enabled', 'remote_ids', + 'authorization_ttl']) id = sql.Column(sql.String(64), primary_key=True) domain_id = sql.Column(sql.String(64), nullable=False) enabled = sql.Column(sql.Boolean, nullable=False) description = sql.Column(sql.Text(), nullable=True) + authorization_ttl = sql.Column(sql.Integer, nullable=True) + remote_ids = orm.relationship('IdPRemoteIdsModel', order_by='IdPRemoteIdsModel.remote_id', cascade='all, delete-orphan') + expiring_user_group_memberships = orm.relationship( + 'ExpiringUserGroupMembership', + cascade='all, delete-orphan', + backref="idp" + ) @classmethod def from_dict(cls, dictionary): diff --git a/keystone/identity/backends/sql_model.py b/keystone/identity/backends/sql_model.py index 8798d326cb..72e86aaaad 100644 --- a/keystone/identity/backends/sql_model.py +++ b/keystone/identity/backends/sql_model.py @@ -61,6 +61,11 @@ class User(sql.ModelBase, sql.ModelDictMixinWithExtras): lazy='joined', cascade='all,delete-orphan', backref='user') + expiring_user_group_memberships = orm.relationship( + 'ExpiringUserGroupMembership', + cascade='all, delete-orphan', + backref="user" + ) created_at = sql.Column(sql.DateTime, nullable=True) last_active_at = sql.Column(sql.Date, nullable=True) # unique constraint needed here to support composite fk constraints @@ -370,6 +375,11 @@ class Group(sql.ModelBase, sql.ModelDictMixinWithExtras): domain_id = sql.Column(sql.String(64), nullable=False) description = sql.Column(sql.Text()) extra = sql.Column(sql.JsonBlob()) + expiring_user_group_memberships = orm.relationship( + 'ExpiringUserGroupMembership', + cascade='all, delete-orphan', + backref="group" + ) # Unique constraint across two columns to create the separation # rather than just only 'name' being unique __table_args__ = (sql.UniqueConstraint('domain_id', 'name'),) @@ -387,6 +397,34 @@ class UserGroupMembership(sql.ModelBase, sql.ModelDictMixin): primary_key=True) +class ExpiringUserGroupMembership(sql.ModelBase, sql.ModelDictMixin): + """Expiring group membership through federation mapping rules.""" + + __tablename__ = 'expiring_user_group_membership' + user_id = sql.Column(sql.String(64), + sql.ForeignKey('user.id'), + primary_key=True) + group_id = sql.Column(sql.String(64), + sql.ForeignKey('group.id'), + primary_key=True) + idp_id = sql.Column(sql.String(64), + sql.ForeignKey('identity_provider.id', + ondelete='CASCADE'), + primary_key=True) + last_verified = sql.Column(sql.DateTime, nullable=False) + + @hybrid_property + def expires(self): + ttl = self.idp.authorization_ttl + if not ttl: + ttl = CONF.federation.default_authorization_ttl + return self.last_verified + datetime.timedelta(minutes=ttl) + + @hybrid_property + def expired(self): + return self.expires <= datetime.datetime.utcnow() + + class UserOption(sql.ModelBase): __tablename__ = 'user_option' user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id', diff --git a/keystone/tests/unit/test_backend_federation_sql.py b/keystone/tests/unit/test_backend_federation_sql.py index d20e076cfc..ffd0f30f9d 100644 --- a/keystone/tests/unit/test_backend_federation_sql.py +++ b/keystone/tests/unit/test_backend_federation_sql.py @@ -23,7 +23,8 @@ class SqlFederation(test_backend_sql.SqlModels): cols = (('id', sql.String, 64), ('domain_id', sql.String, 64), ('enabled', sql.Boolean, None), - ('description', sql.Text, None)) + ('description', sql.Text, None), + ('authorization_ttl', sql.Integer, None)) self.assertExpectedSchema('identity_provider', cols) def test_idp_remote_ids(self): diff --git a/keystone/tests/unit/test_sql_upgrade.py b/keystone/tests/unit/test_sql_upgrade.py index 044fe2c592..22cb442600 100644 --- a/keystone/tests/unit/test_sql_upgrade.py +++ b/keystone/tests/unit/test_sql_upgrade.py @@ -3474,6 +3474,31 @@ class FullMigration(SqlMigrateBase, unit.TestCase): self.assertFalse(self.does_fk_exist('user', 'domain_id')) self.assertFalse(self.does_fk_exist('identity_provider', 'domain_id')) + def test_migration_073_contract_expiring_group_membership(self): + self.expand(72) + self.migrate(72) + self.contract(72) + + membership_table = 'expiring_user_group_membership' + self.assertTableDoesNotExist(membership_table) + + idp_table = 'identity_provider' + self.assertTableColumns( + idp_table, + ['id', 'domain_id', 'enabled', 'description']) + + self.expand(73) + self.migrate(73) + self.contract(73) + + self.assertTableColumns( + membership_table, + ['user_id', 'group_id', 'idp_id', 'last_verified']) + self.assertTableColumns( + idp_table, + ['id', 'domain_id', 'enabled', 'description', + 'authorization_ttl']) + class MySQLOpportunisticFullMigration(FullMigration): FIXTURE = db_fixtures.MySQLOpportunisticFixture