diff --git a/api-ref/source/v3/parameters.yaml b/api-ref/source/v3/parameters.yaml index 8ea55dabe6..d875fe6a24 100644 --- a/api-ref/source/v3/parameters.yaml +++ b/api-ref/source/v3/parameters.yaml @@ -1244,6 +1244,14 @@ links_user: in: body required: true type: object +membership_expires_at_response_body: + description: | + The date and time when the group membership expires. + A ``null`` value indicates that the membership never expires. + in: body + required: true + type: string + min_version: 3.14 original_password: description: | The original password for the user. diff --git a/api-ref/source/v3/samples/admin/user-groups-list-response.json b/api-ref/source/v3/samples/admin/user-groups-list-response.json index 9dff9404d4..be1a3b99c2 100644 --- a/api-ref/source/v3/samples/admin/user-groups-list-response.json +++ b/api-ref/source/v3/samples/admin/user-groups-list-response.json @@ -7,6 +7,7 @@ "links": { "self": "https://example.com/identity/v3/groups/ea167b" }, + "membership_expires_at": null, "name": "Developers" }, { @@ -16,6 +17,7 @@ "links": { "self": "https://example.com/identity/v3/groups/a62db1" }, + "membership_expires_at": "2016-11-06T15:32:17.000000", "name": "Secure Developers" } ], diff --git a/api-ref/source/v3/users.inc b/api-ref/source/v3/users.inc index aee2d33860..ed5208e491 100644 --- a/api-ref/source/v3/users.inc +++ b/api-ref/source/v3/users.inc @@ -343,6 +343,20 @@ Parameters Response -------- +Parameters +~~~~~~~~~~ + +.. rest_parameters:: parameters.yaml + + - links: link_collection + - groups: groups + - description: group_description_response_body + - domain_id: group_domain_id_response_body + - id: group_id_response_body + - links: link_response_body + - name: group_name_response_body + - membership_expires_at: membership_expires_at_response_body + Status Codes ~~~~~~~~~~~~ diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index eed3496309..cbe98305f6 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -160,6 +160,13 @@ class Identity(base.IdentityDriverBase): 'password_expires_at'] return query, hints + @staticmethod + def _apply_limits_to_list(collection, hints): + if not hints.limit: + return collection + + return collection[:hints.limit['limit']] + @driver_hints.truncated def list_users(self, hints): with sql.session_for_read() as session: @@ -281,14 +288,28 @@ class Identity(base.IdentityDriverBase): with sql.session_for_read() as session: self.get_group(group_id) self.get_user(user_id) + + # Note(knikolla): Check for normal group membership query = session.query(model.UserGroupMembership) query = query.filter_by(user_id=user_id) query = query.filter_by(group_id=group_id) - if not query.first(): - raise exception.NotFound(_("User '%(user_id)s' not found in" - " group '%(group_id)s'") % - {'user_id': user_id, - 'group_id': group_id}) + if query.first(): + return + + # Note(knikolla): Check for expiring group membership + query = session.query(model.ExpiringUserGroupMembership) + query = query.filter( + model.ExpiringUserGroupMembership.user_id == user_id) + query = query.filter( + model.ExpiringUserGroupMembership.group_id == group_id) + active = [q for q in query.all() if not q.expired] + if active: + return + + raise exception.NotFound(_("User '%(user_id)s' not found in" + " group '%(group_id)s'") % + {'user_id': user_id, + 'group_id': group_id}) def remove_user_from_group(self, user_id, group_id): # We don't check if user or group are still valid and let the remove @@ -310,12 +331,33 @@ class Identity(base.IdentityDriverBase): session.delete(membership_ref) def list_groups_for_user(self, user_id, hints): + def row_to_group_dict(row): + group = row.group.to_dict() + group['membership_expires_at'] = row.expires + return group + with sql.session_for_read() as session: self.get_user(user_id) query = session.query(model.Group).join(model.UserGroupMembership) query = query.filter(model.UserGroupMembership.user_id == user_id) query = sql.filter_limit_query(model.Group, query, hints) - return [g.to_dict() for g in query] + groups = [g.to_dict() for g in query] + + # Note(knikolla): We must use the ExpiringGroupMembership model + # so that we can access the expired property. + query = session.query(model.ExpiringUserGroupMembership) + query = query.filter( + model.ExpiringUserGroupMembership.user_id == user_id) + query = sql.filter_limit_query( + model.UserGroupMembership, query, hints) + expiring_groups = [row_to_group_dict(r) for r in query.all() + if not r.expired] + + # Note(knikolla): I would have loved to be able to merge the two + # queries together and use filter_limit_query on the union, but + # I haven't found a generic way to express expiration in a SQL + # query, therefore we have to apply the limits here again. + return self._apply_limits_to_list(groups + expiring_groups, hints) def list_users_in_group(self, group_id, hints): with sql.session_for_read() as session: diff --git a/keystone/identity/core.py b/keystone/identity/core.py index 2d0c266db7..4aa489a12a 100644 --- a/keystone/identity/core.py +++ b/keystone/identity/core.py @@ -1309,6 +1309,9 @@ class Manager(manager.Manager): # driver selection, so remove any such filter self._mark_domain_id_filter_satisfied(hints) ref_list = driver.list_groups_for_user(entity_id, hints) + for ref in ref_list: + if 'membership_expires_at' not in ref: + ref['membership_expires_at'] = None return self._set_domain_id_and_mapping( ref_list, domain_id, driver, mapping.EntityType.GROUP) diff --git a/keystone/identity/shadow_backends/sql.py b/keystone/identity/shadow_backends/sql.py index 5ac8b6469a..3dbcfea392 100644 --- a/keystone/identity/shadow_backends/sql.py +++ b/keystone/identity/shadow_backends/sql.py @@ -195,3 +195,32 @@ class ShadowUsers(base.ShadowUsersDriverBase): fed_user_refs = sql.filter_limit_query(model.FederatedUser, query, hints) return [x.to_dict() for x in fed_user_refs] + + def add_user_to_group_expires(self, user_id, group_id): + def get_federated_user(): + with sql.session_for_read() as session: + query = session.query(model.FederatedUser) + query = query.filter_by(user_id=user_id) + user = query.first() + if not user: + # Note(knikolla): This shouldn't really ever happen, since + # this requires the user to already be logged in. + raise exception.UserNotFound() + return user + + with sql.session_for_write() as session: + user = get_federated_user() + query = session.query(model.ExpiringUserGroupMembership) + query = query.filter_by(user_id=user_id) + query = query.filter_by(group_id=group_id) + membership = query.first() + + if membership: + membership.last_verified = datetime.datetime.utcnow() + else: + session.add(model.ExpiringUserGroupMembership( + user_id=user_id, + group_id=group_id, + idp_id=user.idp_id, + last_verified=datetime.datetime.utcnow() + )) diff --git a/keystone/tests/unit/test_backend_ldap.py b/keystone/tests/unit/test_backend_ldap.py index 560c5da252..adb3547649 100644 --- a/keystone/tests/unit/test_backend_ldap.py +++ b/keystone/tests/unit/test_backend_ldap.py @@ -984,6 +984,8 @@ class BaseLDAPIdentity(LDAPTestSetup, IdentityTests, AssignmentTests, # List groups for user. ref_list = PROVIDERS.identity_api.list_groups_for_user(public_user_id) + for ref in ref_list: + del(ref['membership_expires_at']) group['id'] = public_group_id self.assertThat(ref_list, matchers.Equals([group])) diff --git a/keystone/tests/unit/test_backend_sql.py b/keystone/tests/unit/test_backend_sql.py index a5e269d44a..c0bff3aaa2 100644 --- a/keystone/tests/unit/test_backend_sql.py +++ b/keystone/tests/unit/test_backend_sql.py @@ -16,6 +16,7 @@ import datetime from unittest import mock import uuid +import freezegun from oslo_db import exception as db_exception from oslo_db import options import sqlalchemy @@ -671,6 +672,94 @@ class SqlIdentity(SqlTests, negative_user['id']) self.assertEqual(0, len(group_refs)) + def test_add_user_to_group_expiring(self): + self._build_fed_resource() + domain = self._get_domain_fixture() + time = datetime.datetime.utcnow() + tick = datetime.timedelta(minutes=5) + + new_group = unit.new_group_ref(domain_id=domain['id']) + new_group = PROVIDERS.identity_api.create_group(new_group) + + fed_dict = unit.new_federated_user_ref() + fed_dict['idp_id'] = 'myidp' + fed_dict['protocol_id'] = 'mapped' + new_user = PROVIDERS.shadow_users_api.create_federated_user( + domain['id'], fed_dict + ) + + with freezegun.freeze_time(time - tick) as frozen_time: + PROVIDERS.shadow_users_api.add_user_to_group_expires( + new_user['id'], new_group['id']) + + self.config_fixture.config(group='federation', + default_authorization_ttl=0) + self.assertRaises(exception.NotFound, + PROVIDERS.identity_api.check_user_in_group, + new_user['id'], + new_group['id']) + + self.config_fixture.config(group='federation', + default_authorization_ttl=5) + PROVIDERS.identity_api.check_user_in_group(new_user['id'], + new_group['id']) + + # Expiration + frozen_time.tick(tick) + self.assertRaises(exception.NotFound, + PROVIDERS.identity_api.check_user_in_group, + new_user['id'], + new_group['id']) + + # Renewal + PROVIDERS.shadow_users_api.add_user_to_group_expires( + new_user['id'], new_group['id']) + PROVIDERS.identity_api.check_user_in_group(new_user['id'], + new_group['id']) + + def test_add_user_to_group_expiring_list(self): + self._build_fed_resource() + domain = self._get_domain_fixture() + self.config_fixture.config(group='federation', + default_authorization_ttl=5) + time = datetime.datetime.utcnow() + tick = datetime.timedelta(minutes=5) + + new_group = unit.new_group_ref(domain_id=domain['id']) + new_group = PROVIDERS.identity_api.create_group(new_group) + exp_new_group = unit.new_group_ref(domain_id=domain['id']) + exp_new_group = PROVIDERS.identity_api.create_group(exp_new_group) + + fed_dict = unit.new_federated_user_ref() + fed_dict['idp_id'] = 'myidp' + fed_dict['protocol_id'] = 'mapped' + new_user = PROVIDERS.shadow_users_api.create_federated_user( + domain['id'], fed_dict + ) + + PROVIDERS.identity_api.add_user_to_group(new_user['id'], + new_group['id']) + PROVIDERS.identity_api.check_user_in_group(new_user['id'], + new_group['id']) + + with freezegun.freeze_time(time - tick) as frozen_time: + PROVIDERS.shadow_users_api.add_user_to_group_expires( + new_user['id'], exp_new_group['id']) + PROVIDERS.identity_api.check_user_in_group(new_user['id'], + new_group['id']) + + groups = PROVIDERS.identity_api.list_groups_for_user( + new_user['id']) + self.assertEqual(len(groups), 2) + for group in groups: + if group.get('membership_expires_at'): + self.assertEqual(group['membership_expires_at'], time) + + frozen_time.tick(tick) + groups = PROVIDERS.identity_api.list_groups_for_user( + new_user['id']) + self.assertEqual(len(groups), 1) + def test_storing_null_domain_id_in_project_ref(self): """Test the special storage of domain_id=None in sql resource driver.