From 2e51473138de46a4c735ce81a4a53c374206a86e Mon Sep 17 00:00:00 2001 From: Adam Young Date: Tue, 21 Jan 2014 15:06:48 -0500 Subject: [PATCH] Token Revocation Extension Base API for reporting revocation events. The KVS Backend uses the Dogpile backed KVS stores. Modifies the places that were directly deleting tokens to also generate revocation events. Where possible the revocations are triggered by listening to the notifications. Some places, the callers have been modified instead. This is usually due to the need to iterate through a collection, such as users in a group. Adds a config file option to disable the existing mechanisms that support revoking a token by that token's id: revoke_by_id. This flag is necessary to test that the revocation mechanism is working as defined, but will also be part of the phased removal of the older mechanisms. TokenRevoke tests have been extended to test both with and without revoke-by-id enabled. Note: The links aren't populated in the list_events response. SQL Backend for Revocation Events Initializes the SQL Database for the revocation backend. This patch refactors the sql migration call from the CLI so that the test framework can use it as well. The sql backend for revcations is exercized by test_notifications and must be properly initialized. Revoke By Search Tree Co-Authored-By: Yuriy Taraday (Yoriksar) create a set of nested maps for the events. Look up revocation by traversing down the tree. Blueprint: revocation-events Change-Id: If76c8cd5d01a5b991c58a4d1a9d534b2a3da875a --- doc/source/configuration.rst | 8 + .../extensions/revoke-configuration.rst | 41 ++ etc/keystone-paste.ini | 3 + etc/keystone.conf.sample | 25 ++ etc/policy.json | 3 +- etc/policy.v3cloudsample.json | 3 +- keystone/assignment/core.py | 27 +- keystone/auth/controllers.py | 2 + keystone/cli.py | 46 +- keystone/common/config.py | 22 +- keystone/common/controller.py | 7 + keystone/common/sql/migration_helpers.py | 48 +++ keystone/contrib/revoke/__init__.py | 13 + keystone/contrib/revoke/backends/__init__.py | 0 keystone/contrib/revoke/backends/kvs.py | 65 +++ keystone/contrib/revoke/backends/sql.py | 113 +++++ keystone/contrib/revoke/controllers.py | 41 ++ keystone/contrib/revoke/core.py | 220 ++++++++++ .../contrib/revoke/migrate_repo/__init__.py | 0 .../contrib/revoke/migrate_repo/migrate.cfg | 25 ++ .../migrate_repo/versions/001_revoke_table.py | 47 ++ .../revoke/migrate_repo/versions/__init__.py | 0 keystone/contrib/revoke/model.py | 290 +++++++++++++ keystone/contrib/revoke/routers.py | 26 ++ keystone/exception.py | 7 + keystone/identity/core.py | 29 +- keystone/middleware/core.py | 8 + keystone/service.py | 2 + keystone/tests/_sql_livetest.py | 25 +- keystone/tests/backend_sql.conf | 3 + keystone/tests/core.py | 19 +- keystone/tests/test_content_types.py | 56 +++ keystone/tests/test_overrides.conf | 3 + keystone/tests/test_revoke.py | 405 ++++++++++++++++++ keystone/tests/test_revoke_kvs.conf | 6 + keystone/tests/test_revoke_sql.conf | 6 + keystone/tests/test_sql_migrate_extensions.py | 25 ++ keystone/tests/test_token_provider.py | 1 + keystone/tests/test_v3_auth.py | 358 +++++++++++++--- keystone/tests/test_v3_os_revoke.py | 116 +++++ keystone/token/backends/kvs.py | 9 + keystone/token/backends/sql.py | 6 + keystone/token/controllers.py | 6 +- keystone/token/core.py | 12 + keystone/token/provider.py | 42 +- keystone/token/providers/common.py | 21 +- 46 files changed, 2108 insertions(+), 132 deletions(-) create mode 100644 doc/source/extensions/revoke-configuration.rst create mode 100644 keystone/contrib/revoke/__init__.py create mode 100644 keystone/contrib/revoke/backends/__init__.py create mode 100644 keystone/contrib/revoke/backends/kvs.py create mode 100644 keystone/contrib/revoke/backends/sql.py create mode 100644 keystone/contrib/revoke/controllers.py create mode 100644 keystone/contrib/revoke/core.py create mode 100644 keystone/contrib/revoke/migrate_repo/__init__.py create mode 100644 keystone/contrib/revoke/migrate_repo/migrate.cfg create mode 100644 keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py create mode 100644 keystone/contrib/revoke/migrate_repo/versions/__init__.py create mode 100644 keystone/contrib/revoke/model.py create mode 100644 keystone/contrib/revoke/routers.py create mode 100644 keystone/tests/test_revoke.py create mode 100644 keystone/tests/test_revoke_kvs.conf create mode 100644 keystone/tests/test_revoke_sql.conf create mode 100644 keystone/tests/test_v3_os_revoke.py diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 4ac276f447..fd3c090408 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -856,6 +856,14 @@ Federation extensions/federation-configuration.rst +Revocation Events +------------------ + +.. toctree:: + :maxdepth: 1 + + extensions/revoke-configuration.rst + .. _`prepare your deployment`: Preparing your deployment diff --git a/doc/source/extensions/revoke-configuration.rst b/doc/source/extensions/revoke-configuration.rst new file mode 100644 index 0000000000..fee0d1ced3 --- /dev/null +++ b/doc/source/extensions/revoke-configuration.rst @@ -0,0 +1,41 @@ + .. + 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. + +================================ +Enabling the OS-REVOKE Extension +================================ + +To enable the ``OS-REVOKE`` extension: + +1. Add the driver fields and values in the ``[revoke]`` section + in ``keystone.conf``. For the KVS Driver:: + + [revoke] + driver = keystone.contrib.revoke.backends.kvs.Revoke + +For the SQL driver:: + + driver = keystone.contrib.revoke.backends.sql.Revoke + + +2. Add the required ``filter`` to the ``pipeline`` in ``keystone-paste.ini``:: + + [filter:revoke_extension] + paste.filter_factory = keystone.contrib.revoke.routers:RevokeExtension.factory + + [pipeline:api_v3] + pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body revoke_extension service_v3 + +3. Create the extension tables if using the provided SQL backend:: + + ./bin/keystone-manage db_sync --extension revoke diff --git a/etc/keystone-paste.ini b/etc/keystone-paste.ini index 6e0298464c..71de7d40ac 100644 --- a/etc/keystone-paste.ini +++ b/etc/keystone-paste.ini @@ -45,6 +45,9 @@ paste.filter_factory = keystone.contrib.endpoint_filter.routers:EndpointFilterEx [filter:simple_cert_extension] paste.filter_factory = keystone.contrib.simple_cert:SimpleCertExtension.factory +[filter:revoke_extension] +paste.filter_factory = keystone.contrib.revoke.routers:RevokeExtension.factory + [filter:url_normalize] paste.filter_factory = keystone.middleware:NormalizingFilter.factory diff --git a/etc/keystone.conf.sample b/etc/keystone.conf.sample index b93a274bf8..c45c84bb32 100644 --- a/etc/keystone.conf.sample +++ b/etc/keystone.conf.sample @@ -1083,6 +1083,22 @@ #list_limit= +[revoke] + +# +# Options defined in keystone +# + +# An implementation of the backend for persisting revocation +# events. (string value) +#driver=keystone.contrib.revoke.backends.kvs.Revoke + +# This value (calculated in seconds) is added to token +# expiration before a revocation event may be removed from the +# backend. (integer value) +#expiration_buffer=1800 + + [signing] # @@ -1207,6 +1223,15 @@ # global and token caching are enabled. (integer value) #cache_time= +# Revoke token by token identifier. Setting revoke_by_id to +# True enables various forms of enumerating tokens, e.g. `list +# tokens for user`. These enumerations are processed to +# determine the list of tokens to revoke. Only disable if +# you are switching to using the Revoke extension with a +# backend other than KVS, which stores events in memory. +# (boolean value) +#revoke_by_id=true + [trust] diff --git a/etc/policy.json b/etc/policy.json index fbb83a9dcd..9c7e646ef7 100644 --- a/etc/policy.json +++ b/etc/policy.json @@ -138,6 +138,7 @@ "identity:update_mapping": "rule:admin_required", "identity:list_projects_for_groups": "", - "identity:list_domains_for_groups": "" + "identity:list_domains_for_groups": "", + "identity:list_revoke_events": "" } diff --git a/etc/policy.v3cloudsample.json b/etc/policy.v3cloudsample.json index a78fcc12ad..e481eddd60 100644 --- a/etc/policy.v3cloudsample.json +++ b/etc/policy.v3cloudsample.json @@ -150,6 +150,7 @@ "identity:update_mapping": "rule:admin_required", "identity:list_projects_for_groups": "", - "identity:list_domains_for_groups": "" + "identity:list_domains_for_groups": "", + "identity:list_revoke_events": "" } diff --git a/keystone/assignment/core.py b/keystone/assignment/core.py index 325eb2a971..27e8f0309f 100644 --- a/keystone/assignment/core.py +++ b/keystone/assignment/core.py @@ -47,6 +47,7 @@ def calc_default_domain(): @dependency.provider('assignment_api') +@dependency.optional('revoke_api') @dependency.requires('credential_api', 'identity_api', 'token_api') class Manager(manager.Manager): """Default pivot point for the Assignment backend. @@ -264,6 +265,10 @@ class Manager(manager.Manager): self.driver.remove_role_from_user_and_project(user_id, tenant_id, role_id) + if self.revoke_api: + self.revoke_api.revoke_by_grant(role_id, user_id=user_id, + project_id=tenant_id) + except exception.RoleNotFound: LOG.debug(_("Removing role %s failed because it does not " "exist."), @@ -483,20 +488,34 @@ class Manager(manager.Manager): def remove_role_from_user_and_project(self, user_id, tenant_id, role_id): self.driver.remove_role_from_user_and_project(user_id, tenant_id, role_id) - self.token_api.delete_tokens_for_user(user_id) + if CONF.token.revoke_by_id: + self.token_api.delete_tokens_for_user(user_id) + if self.revoke_api: + self.revoke_api.revoke_by_grant(role_id, user_id=user_id, + project_id=tenant_id) def delete_grant(self, role_id, user_id=None, group_id=None, domain_id=None, project_id=None, inherited_to_projects=False): user_ids = [] - if group_id is not None: - # NOTE(morganfainberg): The user ids are the important part for - # invalidating tokens below, so extract them here. + if group_id is None: + if self.revoke_api: + self.revoke_api.revoke_by_grant(user_id=user_id, + role_id=role_id, + domain_id=domain_id, + project_id=project_id) + else: try: + # NOTE(morganfainberg): The user ids are the important part + # for invalidating tokens below, so extract them here. for user in self.identity_api.list_users_in_group(group_id, domain_id): if user['id'] != user_id: user_ids.append(user['id']) + if self.revoke_api: + self.revoke_api.revoke_by_grant( + user_id=user['id'], role_id=role_id, + domain_id=domain_id, project_id=project_id) except exception.GroupNotFound: LOG.debug(_('Group %s not found, no tokens to invalidate.'), group_id) diff --git a/keystone/auth/controllers.py b/keystone/auth/controllers.py index 78acced747..0289f302a2 100644 --- a/keystone/auth/controllers.py +++ b/keystone/auth/controllers.py @@ -448,6 +448,8 @@ class Auth(controller.V3Controller): @controller.protected() def revocation_list(self, context, auth=None): + if not CONF.token.revoke_by_id: + raise exception.Gone() tokens = self.token_api.list_revoked_tokens() for t in tokens: diff --git a/keystone/cli.py b/keystone/cli.py index c0b471b9d4..a9decbde0b 100644 --- a/keystone/cli.py +++ b/keystone/cli.py @@ -16,8 +16,6 @@ from __future__ import absolute_import import os -from migrate import exceptions - from oslo.config import cfg import pbr.version @@ -26,10 +24,6 @@ from keystone.common import sql from keystone.common.sql import migration_helpers from keystone.common import utils from keystone import config -from keystone import contrib -from keystone import exception -from keystone.openstack.common.db.sqlalchemy import migration -from keystone.openstack.common import importutils from keystone import token CONF = config.CONF @@ -70,28 +64,7 @@ class DbSync(BaseApp): def main(): version = CONF.command.version extension = CONF.command.extension - if not extension: - abs_path = migration_helpers.find_migrate_repo() - else: - try: - package_name = '.'.join((contrib.__name__, extension)) - package = importutils.import_module(package_name) - except ImportError: - raise ImportError(_("%s extension does not exist.") - % package_name) - try: - abs_path = migration_helpers.find_migrate_repo(package) - try: - migration.db_version_control(abs_path) - # Register the repo with the version control API - # If it already knows about the repo, it will throw - # an exception that we can safely ignore - except exceptions.DatabaseAlreadyControlledError: - pass - except exception.MigrationNotProvided as e: - print(e) - exit(0) - migration.db_sync(abs_path, version=version) + migration_helpers.sync_database_to_version(extension, version) class DbVersion(BaseApp): @@ -110,22 +83,7 @@ class DbVersion(BaseApp): @staticmethod def main(): extension = CONF.command.extension - if extension: - try: - package_name = '.'.join((contrib.__name__, extension)) - package = importutils.import_module(package_name) - except ImportError: - raise ImportError(_("%s extension does not exist.") - % package_name) - try: - print(migration.db_version( - migration_helpers.find_migrate_repo(package), 0)) - except exception.MigrationNotProvided as e: - print(e) - exit(0) - else: - print(migration.db_version( - migration_helpers.find_migrate_repo(), 0)) + migration_helpers.print_db_version(extension) class BaseCertificateSetup(BaseApp): diff --git a/keystone/common/config.py b/keystone/common/config.py index 3e3b037d59..71d93e87e9 100644 --- a/keystone/common/config.py +++ b/keystone/common/config.py @@ -186,7 +186,27 @@ FILE_OPTIONS = { cfg.IntOpt('cache_time', default=None, help='Time to cache tokens (in seconds). This has no ' 'effect unless global and token caching are ' - 'enabled.')], + 'enabled.'), + cfg.BoolOpt('revoke_by_id', default=True, + help='Revoke token by token identifier. Setting ' + 'revoke_by_id to True enables various forms of ' + 'enumerating tokens, e.g. `list tokens for user`. ' + 'These enumerations are processed to determine the ' + 'list of tokens to revoke. Only disable if you are ' + 'switching to using the Revoke extension with a ' + 'backend other than KVS, which stores events in memory.') + ], + 'revoke': [ + cfg.StrOpt('driver', + default='keystone.contrib.revoke.backends.kvs.Revoke', + help='An implementation of the backend for persisting ' + 'revocation events.'), + cfg.IntOpt('expiration_buffer', default=1800, + help='This value (calculated in seconds) is added to token ' + 'expiration before a revocation event may be removed ' + 'from the backend.'), + + ], 'cache': [ cfg.StrOpt('config_prefix', default='cache.keystone', help='Prefix for building the configuration dictionary ' diff --git a/keystone/common/controller.py b/keystone/common/controller.py index 04c1831fec..b624530942 100644 --- a/keystone/common/controller.py +++ b/keystone/common/controller.py @@ -49,6 +49,13 @@ def _build_policy_check_credentials(self, action, context, kwargs): try: LOG.debug(_('RBAC: building auth context from the incoming ' 'auth token')) + # TODO(ayoung): These two functions return the token in different + # formats. However, the call + # to get_token hits the caching layer, and does not validate the + # token. This should be reduced to one call + if not CONF.token.revoke_by_id: + self.token_api.token_provider_api.validate_token( + context['token_id']) token_ref = self.token_api.get_token(context['token_id']) except exception.TokenNotFound: LOG.warning(_('RBAC: Invalid token')) diff --git a/keystone/common/sql/migration_helpers.py b/keystone/common/sql/migration_helpers.py index df98bdb398..ba150d8027 100644 --- a/keystone/common/sql/migration_helpers.py +++ b/keystone/common/sql/migration_helpers.py @@ -15,12 +15,17 @@ # under the License. import os +import sys import migrate +from migrate import exceptions import sqlalchemy from keystone.common import sql +from keystone import contrib from keystone import exception +from keystone.openstack.common.db.sqlalchemy import migration +from keystone.openstack.common import importutils # Different RDBMSs use different schemes for naming the Foreign Key @@ -106,3 +111,46 @@ def find_migrate_repo(package=None, repo_name='migrate_repo'): if os.path.isdir(path): return path raise exception.MigrationNotProvided(package.__name__, path) + + +def sync_database_to_version(extension=None, version=None): + if not extension: + abs_path = find_migrate_repo() + else: + try: + package_name = '.'.join((contrib.__name__, extension)) + package = importutils.import_module(package_name) + except ImportError: + raise ImportError(_("%s extension does not exist.") + % package_name) + try: + abs_path = find_migrate_repo(package) + try: + migration.db_version_control(abs_path) + # Register the repo with the version control API + # If it already knows about the repo, it will throw + # an exception that we can safely ignore + except exceptions.DatabaseAlreadyControlledError: + pass + except exception.MigrationNotProvided as e: + print(e) + sys.exit(1) + migration.db_sync(abs_path, version=version) + + +def print_db_version(extension=None): + if not extension: + print(migration.db_version(find_migrate_repo(), 0)) + else: + try: + package_name = '.'.join((contrib.__name__, extension)) + package = importutils.import_module(package_name) + except ImportError: + raise ImportError(_("%s extension does not exist.") + % package_name) + try: + print(migration.db_version( + find_migrate_repo(package), 0)) + except exception.MigrationNotProvided as e: + print(e) + sys.exit(1) diff --git a/keystone/contrib/revoke/__init__.py b/keystone/contrib/revoke/__init__.py new file mode 100644 index 0000000000..d78fcfaf8b --- /dev/null +++ b/keystone/contrib/revoke/__init__.py @@ -0,0 +1,13 @@ +# 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. + +from keystone.contrib.revoke.core import * # flake8: noqa diff --git a/keystone/contrib/revoke/backends/__init__.py b/keystone/contrib/revoke/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/contrib/revoke/backends/kvs.py b/keystone/contrib/revoke/backends/kvs.py new file mode 100644 index 0000000000..de87f1b8fe --- /dev/null +++ b/keystone/contrib/revoke/backends/kvs.py @@ -0,0 +1,65 @@ +# 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 datetime + +from keystone.common import kvs +from keystone import config +from keystone.contrib import revoke +from keystone import exception +from keystone.openstack.common import timeutils + + +CONF = config.CONF + +_EVENT_KEY = 'os-revoke-events' +_KVS_BACKEND = 'openstack.kvs.Memory' + + +class Revoke(revoke.Driver): + def __init__(self, **kwargs): + super(Revoke, self).__init__() + self._store = kvs.get_key_value_store('os-revoke-driver') + self._store.configure(backing_store=_KVS_BACKEND, **kwargs) + + def _get_event(self): + try: + return self._store.get(_EVENT_KEY) + except exception.NotFound: + return [] + + def _prune_expired_events_and_get(self, last_fetch=None, new_event=None): + pruned = [] + results = [] + expire_delta = datetime.timedelta(seconds=CONF.token.expiration) + oldest = timeutils.utcnow() - expire_delta + # TODO(ayoung): Store the time of the oldest event so that the + # prune process can be skipped if none of the events have timed out. + with self._store.get_lock(_EVENT_KEY) as lock: + events = self._get_event() + if new_event is not None: + events.append(new_event) + + for event in events: + revoked_at = event.revoked_at + if revoked_at > oldest: + pruned.append(event) + if last_fetch is None or revoked_at > last_fetch: + results.append(event) + self._store.set(_EVENT_KEY, pruned, lock) + return results + + def get_events(self, last_fetch=None): + return self._prune_expired_events_and_get(last_fetch=last_fetch) + + def revoke(self, event): + self._prune_expired_events_and_get(new_event=event) diff --git a/keystone/contrib/revoke/backends/sql.py b/keystone/contrib/revoke/backends/sql.py new file mode 100644 index 0000000000..bbf729fc63 --- /dev/null +++ b/keystone/contrib/revoke/backends/sql.py @@ -0,0 +1,113 @@ +# 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 + +from keystone.common import config +from keystone.common import sql +from keystone.contrib import revoke +from keystone.contrib.revoke import model + +from keystone.openstack.common.db.sqlalchemy import session as db_session + + +CONF = config.CONF + + +class RevocationEvent(sql.ModelBase, sql.ModelDictMixin): + __tablename__ = 'revocation_event' + attributes = model.REVOKE_KEYS + + # The id field is not going to be exposed to the outside world. + # It is, however, necessary for SQLAlchemy. + id = sql.Column(sql.String(64), primary_key=True) + domain_id = sql.Column(sql.String(64)) + project_id = sql.Column(sql.String(64)) + user_id = sql.Column(sql.String(64)) + role_id = sql.Column(sql.String(64)) + trust_id = sql.Column(sql.String(64)) + consumer_id = sql.Column(sql.String(64)) + access_token_id = sql.Column(sql.String(64)) + issued_before = sql.Column(sql.DateTime(), nullable=False) + expires_at = sql.Column(sql.DateTime()) + revoked_at = sql.Column(sql.DateTime(), nullable=False) + + +class Revoke(revoke.Driver): + def _flush_batch_size(self, dialect): + batch_size = 0 + if dialect == 'ibm_db_sa': + # This functionality is limited to DB2, because + # it is necessary to prevent the transaction log + # from filling up, whereas at least some of the + # other supported databases do not support update + # queries with LIMIT subqueries nor do they appear + # to require the use of such queries when deleting + # large numbers of records at once. + batch_size = 100 + # Limit of 100 is known to not fill a transaction log + # of default maximum size while not significantly + # impacting the performance of large token purges on + # systems where the maximum transaction log size has + # been increased beyond the default. + return batch_size + + def _prune_expired_events(self): + oldest = revoke.revoked_before_cutoff_time() + + session = db_session.get_session() + dialect = session.bind.dialect.name + batch_size = self._flush_batch_size(dialect) + if batch_size > 0: + query = session.query(RevocationEvent.id) + query = query.filter(RevocationEvent.revoked_at < oldest) + query = query.limit(batch_size).subquery() + delete_query = (session.query(RevocationEvent). + filter(RevocationEvent.id.in_(query))) + while True: + rowcount = delete_query.delete(synchronize_session=False) + if rowcount == 0: + break + else: + query = session.query(RevocationEvent) + query = query.filter(RevocationEvent.revoked_at < oldest) + query.delete(synchronize_session=False) + + session.flush() + + def get_events(self, last_fetch=None): + self._prune_expired_events() + session = db_session.get_session() + query = session.query(RevocationEvent).order_by( + RevocationEvent.revoked_at) + + if last_fetch: + query.filter(RevocationEvent.revoked_at >= last_fetch) + # While the query filter should handle this, it does not + # appear to be working. It might be a SQLite artifact. + events = [model.RevokeEvent(**e.to_dict()) + for e in query + if e.revoked_at > last_fetch] + else: + events = [model.RevokeEvent(**e.to_dict()) for e in query] + + return events + + def revoke(self, event): + kwargs = dict() + for attr in model.REVOKE_KEYS: + kwargs[attr] = getattr(event, attr) + kwargs['id'] = uuid.uuid4().hex + record = RevocationEvent(**kwargs) + session = db_session.get_session() + with session.begin(): + session.add(record) diff --git a/keystone/contrib/revoke/controllers.py b/keystone/contrib/revoke/controllers.py new file mode 100644 index 0000000000..67b4b1db57 --- /dev/null +++ b/keystone/contrib/revoke/controllers.py @@ -0,0 +1,41 @@ +# 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. + +from keystone.common import controller +from keystone.common import dependency +from keystone import exception +from keystone.openstack.common import timeutils + + +@dependency.requires('revoke_api') +class RevokeController(controller.V3Controller): + @controller.protected() + def list_revoke_events(self, context): + since = context['query_string'].get('since') + last_fetch = None + if since: + try: + last_fetch = timeutils.normalize_time( + timeutils.parse_isotime(since)) + except ValueError: + raise exception.ValidationError( + message=_('invalid date format %s') % since) + events = self.revoke_api.get_events(last_fetch=last_fetch) + # Build the links by hand as the standard controller calls require ids + response = {'events': [event.to_dict() for event in events], + 'links': { + 'next': None, + 'self': RevokeController.base_url( + path=context['path']) + '/events', + 'previous': None} + } + return response diff --git a/keystone/contrib/revoke/core.py b/keystone/contrib/revoke/core.py new file mode 100644 index 0000000000..b02f34dc97 --- /dev/null +++ b/keystone/contrib/revoke/core.py @@ -0,0 +1,220 @@ +# 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 abc +import datetime + +import six + +from keystone.common import dependency +from keystone.common import extension +from keystone.common import kvs +from keystone.common import manager +from keystone import config +from keystone.contrib.revoke import model +from keystone import exception +from keystone import notifications +from keystone.openstack.common import log +from keystone.openstack.common import timeutils + + +CONF = config.CONF +LOG = log.getLogger(__name__) + + +EXTENSION_DATA = { + 'name': 'OpenStack Revoke API', + 'namespace': 'http://docs.openstack.org/identity/api/ext/' + 'OS-REVOKE/v1.0', + 'alias': 'OS-REVOKE', + 'updated': '2014-02-24T20:51:0-00:00', + 'description': 'OpenStack revoked token reporting mechanism.', + 'links': [ + { + 'rel': 'describedby', + 'type': 'text/html', + 'href': ('https://github.com/openstack/identity-api/blob/master/' + 'openstack-identity-api/v3/src/markdown/' + 'identity-api-v3-os-revoke-ext.md'), + } + ]} +extension.register_admin_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) +extension.register_public_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) + + +def revoked_before_cutoff_time(): + expire_delta = datetime.timedelta( + seconds=CONF.token.expiration + CONF.revoke.expiration_buffer) + oldest = timeutils.utcnow() - expire_delta + return oldest + + +_TREE_KEY = 'os-revoke-tree' +_KVS_BACKEND = 'openstack.kvs.Memory' + + +class _Cache(object): + def __init__(self, **kwargs): + self._store = kvs.get_key_value_store('os-revoke-synchonize') + self._store.configure(backing_store=_KVS_BACKEND, **kwargs) + self._last_fetch = None + self._current_events = [] + self.revoke_map = model.RevokeTree() + + def synchronize_revoke_map(self, driver): + cutoff = revoked_before_cutoff_time() + + with self._store.get_lock(_TREE_KEY): + for e in self._current_events: + if e.revoked_at < cutoff: + self.revoke_map.remove(e) + self._current_events.remove(e) + else: + break + events = driver.get_events(last_fetch=self._last_fetch) + self._last_fetch = timeutils.utcnow() + self.revoke_map.add_events(events) + self._current_events = self._current_events + events + + +@dependency.provider('revoke_api') +class Manager(manager.Manager): + """Revoke API Manager. + + Performs common logic for recording revocations. + + """ + + def __init__(self): + super(Manager, self).__init__(CONF.revoke.driver) + self._register_listeners() + self._cache = _Cache() + + def _user_callback(self, service, resource_type, operation, + payload): + self.revoke_by_user(payload['resource_info']) + + def _role_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(role_id=payload['resource_info'])) + + def _project_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(project_id=payload['resource_info'])) + + def _domain_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(domain_id=payload['resource_info'])) + + def _trust_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(trust_id=payload['resource_info'])) + + def _consumer_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(consumer_id=payload['resource_info'])) + + def _access_token_callback(self, service, resource_type, operation, + payload): + self.driver.revoke( + model.RevokeEvent(access_token_id=payload['resource_info'])) + + def _register_listeners(self): + callbacks = [ + ['deleted', 'OS-TRUST:trust', self._trust_callback], + ['deleted', 'OS-OAUTH1:consumer', self._consumer_callback], + ['deleted', 'OS-OAUTH1:access_token', + self._access_token_callback], + ['deleted', 'role', self._role_callback], + ['deleted', 'user', self._user_callback], + ['disabled', 'user', self._user_callback], + ['deleted', 'project', self._project_callback], + ['disabled', 'project', self._project_callback], + ['disabled', 'domain', self._domain_callback]] + for cb in callbacks: + notifications.register_event_callback(*cb) + + def revoke_by_user(self, user_id): + return self.driver.revoke(model.RevokeEvent(user_id=user_id)) + + def revoke_by_expiration(self, user_id, expires_at): + self.driver.revoke( + model.RevokeEvent(user_id=user_id, + expires_at=expires_at)) + + def revoke_by_grant(self, role_id, user_id=None, + domain_id=None, project_id=None): + self.driver.revoke( + model.RevokeEvent(user_id=user_id, + role_id=role_id, + domain_id=domain_id, + project_id=project_id)) + + def revoke_by_user_and_project(self, user_id, project_id): + self.driver.revoke( + model.RevokeEvent(project_id=project_id, + user_id=user_id)) + + def revoke_by_project_role_assignment(self, project_id, role_id): + self.driver.revoke(model.RevokeEvent(project_id=project_id, + role_id=role_id)) + + def revoke_by_domain_role_assignment(self, domain_id, role_id): + self.driver.revoke(model.RevokeEvent(domain_id=domain_id, + role_id=role_id)) + + def check_token(self, token_values): + """Checks the values from a token against the revocation list + + :param token_values: dictionary of values from a token, + normalized for differences between v2 and v3. The checked values are a + subset of the attributes of model.TokenEvent + + :raises exception.TokenNotFound: if the token is invalid + + """ + self._cache.synchronize_revoke_map(self.driver) + if self._cache.revoke_map.is_revoked(token_values): + raise exception.TokenNotFound(_('Failed to validate token')) + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + """Interface for recording and reporting revocation events.""" + + @abc.abstractmethod + def get_events(self, last_fetch=None): + """return the revocation events, as a list of objects + + :param last_fetch: Time of last fetch. Return all events newer. + :returns: A list of keystone.contrib.revoke.model.RevokeEvent + newer than `last_fetch.` + If no last_fetch is specified, returns all events + for tokens issued after the expiration cutoff. + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def revoke(self, event): + """register a revocation event + + :param event: An instance of + keystone.contrib.revoke.model.RevocationEvent + + """ + raise exception.NotImplemented() diff --git a/keystone/contrib/revoke/migrate_repo/__init__.py b/keystone/contrib/revoke/migrate_repo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/contrib/revoke/migrate_repo/migrate.cfg b/keystone/contrib/revoke/migrate_repo/migrate.cfg new file mode 100644 index 0000000000..0e61bcaa27 --- /dev/null +++ b/keystone/contrib/revoke/migrate_repo/migrate.cfg @@ -0,0 +1,25 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=revoke + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] + +# When creating new change scripts, Migrate will stamp the new script with +# a version number. By default this is latest_version + 1. You can set this +# to 'true' to tell Migrate to use the UTC timestamp instead. +use_timestamp_numbering=False diff --git a/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py b/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py new file mode 100644 index 0000000000..7927ce0c53 --- /dev/null +++ b/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.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): + # Upgrade operations go here. Don't create your own engine; bind + # migrate_engine to your metadata + meta = sql.MetaData() + meta.bind = migrate_engine + + service_table = sql.Table( + 'revocation_event', + meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('domain_id', sql.String(64)), + sql.Column('project_id', sql.String(64)), + sql.Column('user_id', sql.String(64)), + sql.Column('role_id', sql.String(64)), + sql.Column('trust_id', sql.String(64)), + sql.Column('consumer_id', sql.String(64)), + sql.Column('access_token_id', sql.String(64)), + sql.Column('issued_before', sql.DateTime(), nullable=False), + sql.Column('expires_at', sql.DateTime()), + sql.Column('revoked_at', sql.DateTime(), index=True, nullable=False)) + service_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + # Operations to reverse the above upgrade go here. + meta = sql.MetaData() + meta.bind = migrate_engine + + tables = ['revocation_event'] + for t in tables: + table = sql.Table(t, meta, autoload=True) + table.drop(migrate_engine, checkfirst=True) diff --git a/keystone/contrib/revoke/migrate_repo/versions/__init__.py b/keystone/contrib/revoke/migrate_repo/versions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/contrib/revoke/model.py b/keystone/contrib/revoke/model.py new file mode 100644 index 0000000000..a4e85448d1 --- /dev/null +++ b/keystone/contrib/revoke/model.py @@ -0,0 +1,290 @@ +# 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. + +from keystone.openstack.common import timeutils + +# The set of attributes common between the RevokeEvent +# and the dictionaries created from the token Data. +_NAMES = ['trust_id', + 'consumer_id', + 'access_token_id', + 'expires_at', + 'domain_id', + 'project_id', + 'user_id', + 'role_id'] + + +# Additional arguments for creating a RevokeEvent +_EVENT_ARGS = ['issued_before', 'revoked_at'] + +# Values that will be in the token data but not in the event. +# These will compared with event values that have different names. +# For example: both trustor_id and trustee_id are compared against user_id +_TOKEN_KEYS = ['identity_domain_id', + 'assignment_domain_id', + 'issued_at', + 'trustor_id', + 'trustee_id'] + + +REVOKE_KEYS = _NAMES + _EVENT_ARGS + + +def blank_token_data(issued_at): + token_data = dict() + for name in _NAMES: + token_data[name] = None + for name in _TOKEN_KEYS: + token_data[name] = None + # required field + token_data['issued_at'] = issued_at + return token_data + + +class RevokeEvent(object): + def __init__(self, **kwargs): + for k in REVOKE_KEYS: + v = kwargs.get(k, None) + setattr(self, k, v) + if self.revoked_at is None: + self.revoked_at = timeutils.utcnow() + if self.issued_before is None: + self.issued_before = self.revoked_at + + def to_dict(self): + keys = ['user_id', + 'role_id', + 'domain_id', + 'project_id'] + event = dict((key, self.__dict__[key]) for key in keys + if self.__dict__[key] is not None) + if self.trust_id is not None: + event['OS-TRUST:trust_id'] = self.trust_id + if self.consumer_id is not None: + event['OS-OAUTH1:consumer_id'] = self.consumer_id + if self.consumer_id is not None: + event['OS-OAUTH1:access_token_id'] = self.access_token_id + if self.expires_at is not None: + event['expires_at'] = timeutils.isotime(self.expires_at, + subsecond=True) + if self.issued_before is not None: + event['issued_before'] = timeutils.isotime(self.issued_before, + subsecond=True) + return event + + def key_for_name(self, name): + return "%s=%s" % (name, getattr(self, name) or '*') + + +def attr_keys(event): + return map(event.key_for_name, _NAMES) + + +class RevokeTree(object): + """Fast Revocation Checking Tree Structure + + The Tree is an index to quickly match tokens against events. + Each node is a hashtable of key=value combinations from revocation events. + The + + """ + + def __init__(self, revoke_events=None): + self.revoke_map = dict() + self.add_events(revoke_events) + + def add_event(self, event): + """Updates the tree based on a revocation event. + + Creates any necessary internal nodes in the tree corresponding to the + fields of the revocation event. The leaf node will always be set to + the latest 'issued_before' for events that are otherwise identical. + + :param: Event to add to the tree + + :returns: the event that was passed in. + + """ + revoke_map = self.revoke_map + for key in attr_keys(event): + revoke_map = revoke_map.setdefault(key, {}) + revoke_map['issued_before'] = max( + event.issued_before, revoke_map.get( + 'issued_before', event.issued_before)) + return event + + def remove_event(self, event): + """Update the tree based on the removal of a Revocation Event + + Removes empty nodes from the tree from the leaf back to the root. + + If multiple events trace the same path, but have different + 'issued_before' values, only the last is ever stored in the tree. + So only an exact match on 'issued_before' ever triggers a removal + + :param: Event to remove from the tree + + """ + stack = [] + revoke_map = self.revoke_map + for name in _NAMES: + key = event.key_for_name(name) + nxt = revoke_map.get(key) + if nxt is None: + break + stack.append((revoke_map, key, nxt)) + revoke_map = nxt + else: + if event.issued_before == revoke_map['issued_before']: + revoke_map.pop('issued_before') + for parent, key, child in reversed(stack): + if not any(child): + del parent[key] + + def add_events(self, revoke_events): + return map(self.add_event, revoke_events or []) + + def is_revoked(self, token_data): + """Check if a token matches the revocation event + + Compare the values for each level of the tree with the values from + the token, accounting for attributes that have alternative + keys, and for wildcard matches. + if there is a match, continue down the tree. + if there is no match, exit early. + + token_data is a map based on a flattened view of token. + The required fields are: + + 'expires_at','user_id', 'project_id', 'identity_domain_id', + 'assignment_domain_id', 'trust_id', 'trustor_id', 'trustee_id' + 'consumer_id', 'access_token_id' + + """ + alternatives = { + 'user_id': ['user_id', 'trustor_id', 'trustee_id'], + 'domain_id': ['identity_domain_id', 'assignment_domain_id']} + subnode = [self.revoke_map] + for name in _NAMES: + bundle = [] + wildcard = '%s=*' % (name,) + for tree in subnode: + bundle.append(tree.get(wildcard)) + if name == 'role_id': + for role_id in token_data.get('roles', []): + bundle.append(tree.get('role_id=%s' % role_id)) + else: + for alt_name in alternatives.get(name, [name]): + bundle.append( + tree.get('%s=%s' % (name, token_data[alt_name]))) + bundle = filter(None, bundle) + if not bundle: + return False + subnode = bundle + else: + for leaf in subnode: + issued_before = leaf.get('issued_before') + if issued_before is not None: + if issued_before > token_data['issued_at']: + return True + + +def build_token_values_v2(access, default_domain_id): + token_data = access['token'] + token_values = { + 'expires_at': timeutils.normalize_time( + timeutils.parse_isotime(token_data['expires'])), + 'issued_at': timeutils.normalize_time( + timeutils.parse_isotime(token_data['issued_at']))} + + token_values['user_id'] = access.get('user', {}).get('id') + + project = token_data.get('tenant') + if project is not None: + token_values['project_id'] = project['id'] + else: + token_values['project_id'] = None + + token_values['identity_domain_id'] = default_domain_id + token_values['assignment_domain_id'] = default_domain_id + + trust = token_data.get('trust') + if trust is None: + token_values['trust_id'] = None + token_values['trustor_id'] = None + token_values['trustee_id'] = None + else: + token_values['trust_id'] = trust['id'] + token_values['trustor_id'] = trust['trustor_id'] + token_values['trustee_id'] = trust['trustee_id'] + + token_values['consumer_id'] = None + token_values['access_token_id'] = None + + role_list = [] + # Roles are by ID in metadata and by name in the user section + roles = access.get('metadata', {}).get('roles', []) + for role in roles: + role_list.append(role) + token_values['roles'] = role_list + return token_values + + +def build_token_values(token_data): + token_values = { + 'expires_at': timeutils.normalize_time( + timeutils.parse_isotime(token_data['expires_at'])), + 'issued_at': timeutils.normalize_time( + timeutils.parse_isotime(token_data['issued_at']))} + + user = token_data.get('user') + if user is not None: + token_values['user_id'] = user['id'] + token_values['identity_domain_id'] = user['domain']['id'] + else: + token_values['user_id'] = None + token_values['identity_domain_id'] = None + + project = token_data.get('project', token_data.get('tenant')) + if project is not None: + token_values['project_id'] = project['id'] + token_values['assignment_domain_id'] = project['domain']['id'] + else: + token_values['project_id'] = None + token_values['assignment_domain_id'] = None + + role_list = [] + roles = token_data.get('roles') + if roles is not None: + for role in roles: + role_list.append(role['id']) + token_values['roles'] = role_list + + trust = token_data.get('OS-TRUST:trust') + if trust is None: + token_values['trust_id'] = None + token_values['trustor_id'] = None + token_values['trustee_id'] = None + else: + token_values['trust_id'] = trust['id'] + token_values['trustor_id'] = trust['trustor_user']['id'] + token_values['trustee_id'] = trust['trustee_user']['id'] + + oauth1 = token_data.get('OS-OAUTH1') + if oauth1 is None: + token_values['consumer_id'] = None + token_values['access_token_id'] = None + else: + token_values['consumer_id'] = oauth1['consumer_id'] + token_values['access_token_id'] = oauth1['access_token_id'] + return token_values diff --git a/keystone/contrib/revoke/routers.py b/keystone/contrib/revoke/routers.py new file mode 100644 index 0000000000..5f65ecc4aa --- /dev/null +++ b/keystone/contrib/revoke/routers.py @@ -0,0 +1,26 @@ +# 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. + +from keystone.common import wsgi +from keystone.contrib.revoke import controllers + + +class RevokeExtension(wsgi.ExtensionRouter): + + PATH_PREFIX = '/OS-REVOKE' + + def add_routes(self, mapper): + revoke_controller = controllers.RevokeController() + mapper.connect(self.PATH_PREFIX + '/events', + controller=revoke_controller, + action='list_revoke_events', + conditions=dict(method=['GET'])) diff --git a/keystone/exception.py b/keystone/exception.py index 227a301be4..d99bcaee67 100644 --- a/keystone/exception.py +++ b/keystone/exception.py @@ -294,6 +294,13 @@ class NotImplemented(Error): title = 'Not Implemented' +class Gone(Error): + message_format = _("The service you have requested is no" + " longer available on this server.") + code = 410 + title = 'Gone' + + class ConfigFileNotFound(UnexpectedError): message_format = _("The Keystone configuration file %(config_file)s could " "not be found.") diff --git a/keystone/identity/core.py b/keystone/identity/core.py index 44807c7547..42ca86d5dc 100644 --- a/keystone/identity/core.py +++ b/keystone/identity/core.py @@ -190,6 +190,7 @@ def domains_configured(f): @dependency.provider('identity_api') +@dependency.optional('revoke_api') @dependency.requires('assignment_api', 'credential_api', 'token_api') class Manager(manager.Manager): """Default pivot point for the Identity backend. @@ -340,6 +341,8 @@ class Manager(manager.Manager): user = self._clear_domain_id(user) ref = driver.update_user(user_id, user) if user.get('enabled') is False or user.get('password') is not None: + if self.revoke_api: + self.revoke_api.revoke_by_user(user_id) self.token_api.delete_tokens_for_user(user_id) if not driver.is_domain_aware(): ref = self._set_domain_id(ref, domain_id) @@ -388,18 +391,26 @@ class Manager(manager.Manager): ref = self._set_domain_id(ref, domain_id) return ref + def revoke_tokens_for_group(self, group_id, domain_scope): + # We get the list of users before we attempt the group + # deletion, so that we can remove these tokens after we know + # the group deletion succeeded. + + # TODO(ayoung): revoke based on group and roleids instead + user_ids = [] + for u in self.list_users_in_group(group_id, domain_scope): + user_ids.append(u['id']) + if self.revoke_api: + self.revoke_api.revoke_by_user(u['id']) + self.token_api.delete_tokens_for_users(user_ids) + @notifications.deleted('group') @domains_configured def delete_group(self, group_id, domain_scope=None): domain_id, driver = self._get_domain_id_and_driver(domain_scope) # As well as deleting the group, we need to invalidate # any tokens for the users who are members of the group. - # We get the list of users before we attempt the group - # deletion, so that we can remove these tokens after we know - # the group deletion succeeded. - user_ids = [ - u['id'] for u in self.list_users_in_group(group_id, domain_scope)] - self.token_api.delete_tokens_for_users(user_ids) + self.revoke_tokens_for_group(group_id, domain_scope) driver.delete_group(group_id) @domains_configured @@ -412,6 +423,12 @@ class Manager(manager.Manager): def remove_user_from_group(self, user_id, group_id, domain_scope=None): domain_id, driver = self._get_domain_id_and_driver(domain_scope) driver.remove_user_from_group(user_id, group_id) + # TODO(ayoung) revoking all tokens for a user based on group + # membership is overkill, as we only would need to revoke tokens + # that had role assignments via the group. Calculating those + # assignments would have to be done by the assignment backend. + if self.revoke_api: + self.revoke_api.revoke_by_user(user_id) self.token_api.delete_tokens_for_user(user_id) @manager.response_truncated diff --git a/keystone/middleware/core.py b/keystone/middleware/core.py index 4fa50ac7cc..dbd9933aac 100644 --- a/keystone/middleware/core.py +++ b/keystone/middleware/core.py @@ -231,6 +231,14 @@ class AuthContextMiddleware(wsgi.Middleware): try: token_ref = self.token_api.get_token(token_id) + # TODO(ayoung): These two functions return the token in different + # formats instead of two calls, only make one. However, the call + # to get_token hits the caching layer, and does not validate the + # token. In the future, this should be reduced to one call. + if not CONF.token.revoke_by_id: + self.token_api.token_provider_api.validate_token( + context['token_id']) + # TODO(gyee): validate_token_bind should really be its own # middleware wsgi.validate_token_bind(context, token_ref) diff --git a/keystone/service.py b/keystone/service.py index bb2dd0baa0..ffc7feb6df 100644 --- a/keystone/service.py +++ b/keystone/service.py @@ -24,6 +24,7 @@ from keystone.common import cache from keystone.common import wsgi from keystone import config from keystone.contrib import endpoint_filter +from keystone.contrib import revoke from keystone import controllers from keystone import credential from keystone import identity @@ -55,6 +56,7 @@ def load_backends(): endpoint_filter_api=endpoint_filter.Manager(), identity_api=_IDENTITY_API, policy_api=policy.Manager(), + revoke_api=revoke.Manager(), token_api=token.Manager(), trust_api=trust.Manager(), token_provider_api=token.provider.Manager()) diff --git a/keystone/tests/_sql_livetest.py b/keystone/tests/_sql_livetest.py index ad17d39383..c6577ed9a0 100644 --- a/keystone/tests/_sql_livetest.py +++ b/keystone/tests/_sql_livetest.py @@ -13,6 +13,8 @@ # under the License. from keystone import config +from keystone import tests +from keystone.tests import test_sql_migrate_extensions from keystone.tests import test_sql_upgrade @@ -23,7 +25,7 @@ class PostgresqlMigrateTests(test_sql_upgrade.SqlUpgradeTests): def config_files(self): files = (test_sql_upgrade.SqlUpgradeTests. _config_file_list[:]) - files.append("backend_postgresql.conf") + files.append(tests.dirs.tests("backend_postgresql.conf")) return files @@ -31,7 +33,24 @@ class MysqlMigrateTests(test_sql_upgrade.SqlUpgradeTests): def config_files(self): files = (test_sql_upgrade.SqlUpgradeTests. _config_file_list[:]) - files.append("backend_mysql.conf") + files.append(tests.dirs.tests("backend_mysql.conf")) + return files + + +class PostgresqlRevokeExtensionsTests( + test_sql_migrate_extensions.RevokeExtension): + def config_files(self): + files = (test_sql_upgrade.SqlUpgradeTests. + _config_file_list[:]) + files.append(tests.dirs.tests("backend_postgresql.conf")) + return files + + +class MysqlRevokeExtensionsTests(test_sql_migrate_extensions.RevokeExtension): + def config_files(self): + files = (test_sql_upgrade.SqlUpgradeTests. + _config_file_list[:]) + files.append(tests.dirs.tests("backend_mysql.conf")) return files @@ -39,5 +58,5 @@ class Db2MigrateTests(test_sql_upgrade.SqlUpgradeTests): def config_files(self): files = (test_sql_upgrade.SqlUpgradeTests. _config_file_list[:]) - files.append("backend_db2.conf") + files.append(tests.dirs.tests("backend_db2.conf")) return files diff --git a/keystone/tests/backend_sql.conf b/keystone/tests/backend_sql.conf index b275b2e930..dd2d7d61c8 100644 --- a/keystone/tests/backend_sql.conf +++ b/keystone/tests/backend_sql.conf @@ -25,3 +25,6 @@ driver = keystone.policy.backends.sql.Policy [trust] driver = keystone.trust.backends.sql.Trust + +[revoke] +driver = keystone.contrib.revoke.backends.sql.Revoke \ No newline at end of file diff --git a/keystone/tests/core.py b/keystone/tests/core.py index a762315639..ead9b0cd83 100644 --- a/keystone/tests/core.py +++ b/keystone/tests/core.py @@ -14,6 +14,7 @@ from __future__ import absolute_import import atexit +import copy import functools import os import re @@ -31,6 +32,7 @@ import testtools from testtools import testcase import webob +from keystone.openstack.common.db.sqlalchemy import migration from keystone.openstack.common.fixture import mockpatch from keystone.openstack.common import gettextutils @@ -59,7 +61,6 @@ from keystone.common import utils as common_utils from keystone import config from keystone import exception from keystone import notifications -from keystone.openstack.common.db.sqlalchemy import migration from keystone.openstack.common.db.sqlalchemy import session from keystone.openstack.common.fixture import config as config_fixture from keystone.openstack.common import log @@ -156,7 +157,8 @@ def setup_database(): if os.path.exists(db): os.unlink(db) if not os.path.exists(pristine): - migration.db_sync(migration_helpers.find_migrate_repo()) + migration.db_sync((migration_helpers.find_migrate_repo())) + migration_helpers.sync_database_to_version(extension='revoke') shutil.copyfile(db, pristine) else: shutil.copyfile(pristine, db) @@ -308,6 +310,13 @@ class NoModule(object): class TestCase(testtools.TestCase): + + _config_file_list = [dirs.etc('keystone.conf.sample'), + dirs.tests('test_overrides.conf')] + + def config_files(self): + return copy.copy(self._config_file_list) + def setUp(self): super(TestCase, self).setUp() @@ -330,12 +339,8 @@ class TestCase(testtools.TestCase): self.exit_patch = self.useFixture(mockpatch.PatchObject(sys, 'exit')) self.exit_patch.mock.side_effect = UnexpectedExit - self.config_fixture = self.useFixture(config_fixture.Config(CONF)) - - self.config([dirs.etc('keystone.conf.sample'), - dirs.tests('test_overrides.conf')]) - + self.config(self.config_files()) self.opt(policy_file=dirs.etc('policy.json')) self.logger = self.useFixture(fixtures.FakeLogger(level=logging.DEBUG)) diff --git a/keystone/tests/test_content_types.py b/keystone/tests/test_content_types.py index f99b79091e..238aecbba7 100644 --- a/keystone/tests/test_content_types.py +++ b/keystone/tests/test_content_types.py @@ -18,6 +18,7 @@ import six from keystone.common import extension from keystone import config +from keystone import tests from keystone.tests import rest @@ -194,6 +195,28 @@ class CoreApiTests(object): token=token) self.assertValidAuthenticationResponse(r) + def test_remove_role_revokes_token(self): + self.md_foobar = self.assignment_api.add_role_to_user_and_project( + self.user_foo['id'], + self.tenant_service['id'], + self.role_service['id']) + + token = self.get_scoped_token(tenant_id='service') + r = self.admin_request( + path='/v2.0/tokens/%s' % token, + token=token) + self.assertValidAuthenticationResponse(r) + + self.assignment_api.remove_role_from_user_and_project( + self.user_foo['id'], + self.tenant_service['id'], + self.role_service['id']) + + r = self.admin_request( + path='/v2.0/tokens/%s' % token, + token=token, + expected_status=401) + def test_validate_token_belongs_to(self): token = self.get_scoped_token() path = ('/v2.0/tokens/%s?belongsTo=%s' % (token, @@ -1289,6 +1312,16 @@ class JsonTestCase(RestfulTestCase, CoreApiTests, LegacyV2UsernameTests): expected_status=200) +class RevokeApiJsonTestCase(JsonTestCase): + def config_files(self): + cfg_list = self._config_file_list[:] + cfg_list.append(tests.dirs.tests('test_revoke_kvs.conf')) + return cfg_list + + def test_fetch_revocation_list_admin_200(self): + self.skipTest('Revoke API disables revocation_list.') + + class XmlTestCase(RestfulTestCase, CoreApiTests, LegacyV2UsernameTests): xmlns = 'http://docs.openstack.org/identity/api/v2.0' content_type = 'xml' @@ -1624,3 +1657,26 @@ class XmlTestCase(RestfulTestCase, CoreApiTests, LegacyV2UsernameTests): token=token, expected_status=200, convert=False) + + def test_remove_role_revokes_token(self): + self.md_foobar = self.assignment_api.add_role_to_user_and_project( + self.user_foo['id'], + self.tenant_service['id'], + self.role_service['id']) + + token = self.get_scoped_token(tenant_id='service') + r = self.admin_request( + path='/v2.0/tokens/%s' % token, + token=token) + self.assertValidAuthenticationResponse(r) + + self.assignment_api.remove_role_from_user_and_project( + self.user_foo['id'], + self.tenant_service['id'], + self.role_service['id']) + + # TODO(ayoung): test fails due to XML problem +# r = self.admin_request( +# path='/v2.0/tokens/%s' % token, +# token=token, +# expected_status=401) diff --git a/keystone/tests/test_overrides.conf b/keystone/tests/test_overrides.conf index fadc7ce235..c44363c217 100644 --- a/keystone/tests/test_overrides.conf +++ b/keystone/tests/test_overrides.conf @@ -32,3 +32,6 @@ backends = keystone.tests.test_kvs.KVSBackendForcedKeyMangleFixture, keystone.te methods = external,password,token,oauth1,saml2 oauth1 = keystone.auth.plugins.oauth1.OAuth saml2 = keystone.auth.plugins.saml2.Saml2 + +[revoke] +driver=keystone.contrib.revoke.backends.kvs.Revoke diff --git a/keystone/tests/test_revoke.py b/keystone/tests/test_revoke.py new file mode 100644 index 0000000000..9203ca48fe --- /dev/null +++ b/keystone/tests/test_revoke.py @@ -0,0 +1,405 @@ +# 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 datetime +import uuid + +from keystone.common import dependency +from keystone import config +from keystone.contrib.revoke import model +from keystone.openstack.common import timeutils +from keystone import tests +from keystone.tests import test_backend_sql + + +CONF = config.CONF + + +def _new_id(): + return uuid.uuid4().hex + + +def _future_time(): + expire_delta = datetime.timedelta(seconds=1000) + future_time = timeutils.utcnow() + expire_delta + return future_time + + +def _past_time(): + expire_delta = datetime.timedelta(days=-1000) + past_time = timeutils.utcnow() + expire_delta + return past_time + + +def _sample_blank_token(): + issued_delta = datetime.timedelta(minutes=-2) + issued_at = timeutils.utcnow() + issued_delta + token_data = model.blank_token_data(issued_at) + return token_data + + +def _matches(event, token_values): + """See if the token matches the revocation event. + + Used as a secondary check on the logic to Check + By Tree Below: This is abrute force approach to checking. + Compare each attribute from the event with the corresponding + value from the token. If the event does not have a value for + the attribute, a match is still possible. If the event has a + value for the attribute, and it does not match the token, no match + is possible, so skip the remaining checks. + + :param event one revocation event to match + :param token_values dictionary with set of values taken from the + token + :returns if the token matches the revocation event, indicating the + token has been revoked + """ + + # The token has three attributes that can match the user_id + if event.user_id is not None: + for attribute_name in ['user_id', 'trustor_id', 'trustee_id']: + if event.user_id == token_values[attribute_name]: + break + else: + return False + + # The token has two attributes that can match the domain_id + if event.domain_id is not None: + dom_id_matched = False + for attribute_name in ['user_domain_id', 'project_domain_id']: + if event.domain_id == token_values[attribute_name]: + dom_id_matched = True + break + if not dom_id_matched: + return False + + # If any one check does not match, the while token does + # not match the event. The numerous return False indicate + # that the token is still valid and short-circuits the + # rest of the logic. + attribute_names = ['project_id', + 'expires_at', 'trust_id', 'consumer_id', + 'access_token_id'] + for attribute_name in attribute_names: + if getattr(event, attribute_name) is not None: + if (getattr(event, attribute_name) != + token_values[attribute_name]): + return False + + if event.role_id is not None: + roles = token_values['roles'] + role_found = False + for role in roles: + if event.role_id == role: + role_found = True + break + if not role_found: + return False + if token_values['issued_at'] > event.issued_before: + return False + return True + + +@dependency.requires('revoke_api') +class RevokeTests(object): + def test_list(self): + self.revoke_api.revoke_by_user(user_id=1) + self.assertEqual(1, len(self.revoke_api.get_events())) + + self.revoke_api.revoke_by_user(user_id=2) + self.assertEqual(2, len(self.revoke_api.get_events())) + + def test_list_since(self): + self.revoke_api.revoke_by_user(user_id=1) + self.revoke_api.revoke_by_user(user_id=2) + past = timeutils.utcnow() - datetime.timedelta(seconds=1000) + self.assertEqual(2, len(self.revoke_api.get_events(past))) + future = timeutils.utcnow() + datetime.timedelta(seconds=1000) + self.assertEqual(0, len(self.revoke_api.get_events(future))) + + def test_past_expiry_are_removed(self): + user_id = 1 + self.revoke_api.revoke_by_expiration(user_id, _future_time()) + self.assertEqual(1, len(self.revoke_api.get_events())) + event = model.RevokeEvent() + event.revoked_at = _past_time() + self.revoke_api.revoke(event) + self.assertEqual(1, len(self.revoke_api.get_events())) + + +class SqlRevokeTests(test_backend_sql.SqlTests, RevokeTests): + def setUp(self): + super(SqlRevokeTests, self).setUp() + self.config([tests.dirs.etc('keystone.conf.sample'), + tests.dirs.tests( + 'test_revoke_sql.conf')]) + + +class KvsRevokeTests(tests.TestCase, RevokeTests): + def setUp(self): + super(KvsRevokeTests, self).setUp() + self.config([tests.dirs.etc('keystone.conf.sample'), + tests.dirs.tests( + 'test_revoke_kvs.conf')]) + self.load_backends() + + +class RevokeTreeTests(tests.TestCase): + def setUp(self): + super(RevokeTreeTests, self).setUp() + self.events = [] + self.tree = model.RevokeTree() + self._sample_data() + + def _sample_data(self): + user_ids = [] + project_ids = [] + role_ids = [] + for i in range(0, 3): + user_ids.append(_new_id()) + project_ids.append(_new_id()) + role_ids.append(_new_id()) + + project_tokens = [] + i = len(project_tokens) + project_tokens.append(_sample_blank_token()) + project_tokens[i]['user_id'] = user_ids[0] + project_tokens[i]['project_id'] = project_ids[0] + project_tokens[i]['roles'] = [role_ids[1]] + + i = len(project_tokens) + project_tokens.append(_sample_blank_token()) + project_tokens[i]['user_id'] = user_ids[1] + project_tokens[i]['project_id'] = project_ids[0] + project_tokens[i]['roles'] = [role_ids[0]] + + i = len(project_tokens) + project_tokens.append(_sample_blank_token()) + project_tokens[i]['user_id'] = user_ids[0] + project_tokens[i]['project_id'] = project_ids[1] + project_tokens[i]['roles'] = [role_ids[0]] + + token_to_revoke = _sample_blank_token() + token_to_revoke['user_id'] = user_ids[0] + token_to_revoke['project_id'] = project_ids[0] + token_to_revoke['roles'] = [role_ids[0]] + + self.project_tokens = project_tokens + self.user_ids = user_ids + self.project_ids = project_ids + self.role_ids = role_ids + self.token_to_revoke = token_to_revoke + + def _assertTokenRevoked(self, token_data): + self.assertTrue(any([_matches(e, token_data) for e in self.events])) + return self.assertTrue(self.tree.is_revoked(token_data), + 'Token should be revoked') + + def _assertTokenNotRevoked(self, token_data): + self.assertFalse(any([_matches(e, token_data) for e in self.events])) + return self.assertFalse(self.tree.is_revoked(token_data), + 'Token should not be revoked') + + def _revoke_by_user(self, user_id): + return self.tree.add_event( + model.RevokeEvent(user_id=user_id)) + + def _revoke_by_expiration(self, user_id, expires_at): + event = self.tree.add_event( + model.RevokeEvent(user_id=user_id, + expires_at=expires_at)) + self.events.append(event) + return event + + def _revoke_by_grant(self, role_id, user_id=None, + domain_id=None, project_id=None): + event = self.tree.add_event( + model.RevokeEvent(user_id=user_id, + role_id=role_id, + domain_id=domain_id, + project_id=project_id)) + self.events.append(event) + return event + + def _revoke_by_user_and_project(self, user_id, project_id): + event = self.tree.add_event( + model.RevokeEvent(project_id=project_id, + user_id=user_id)) + self.events.append(event) + return event + + def _revoke_by_project_role_assignment(self, project_id, role_id): + event = self.tree.add_event( + model.RevokeEvent(project_id=project_id, + role_id=role_id)) + self.events.append(event) + return event + + def _revoke_by_domain_role_assignment(self, domain_id, role_id): + event = self.tree.add_event( + model.RevokeEvent(domain_id=domain_id, + role_id=role_id)) + self.events.append(event) + return event + + def _user_field_test(self, field_name): + user_id = _new_id() + event = self._revoke_by_user(user_id) + self.events.append(event) + token_data_u1 = _sample_blank_token() + token_data_u1[field_name] = user_id + self._assertTokenRevoked(token_data_u1) + token_data_u2 = _sample_blank_token() + token_data_u2[field_name] = _new_id() + self._assertTokenNotRevoked(token_data_u2) + self.tree.remove_event(event) + self.events.remove(event) + self._assertTokenNotRevoked(token_data_u1) + + def test_revoke_by_user(self): + self._user_field_test('user_id') + + def test_revoke_by_user_matches_trustee(self): + self._user_field_test('trustee_id') + + def test_revoke_by_user_matches_trustor(self): + self._user_field_test('trustor_id') + + def test_by_user_expiration(self): + future_time = _future_time() + + user_id = 1 + event = self._revoke_by_expiration(user_id, future_time) + token_data_1 = _sample_blank_token() + token_data_1['user_id'] = user_id + token_data_1['expires_at'] = future_time + self._assertTokenRevoked(token_data_1) + + token_data_2 = _sample_blank_token() + token_data_2['user_id'] = user_id + expire_delta = datetime.timedelta(seconds=2000) + future_time = timeutils.utcnow() + expire_delta + token_data_2['expires_at'] = future_time + self._assertTokenNotRevoked(token_data_2) + + self.removeEvent(event) + self._assertTokenNotRevoked(token_data_1) + + def removeEvent(self, event): + self.events.remove(event) + self.tree.remove_event(event) + + def test_by_project_grant(self): + token_to_revoke = self.token_to_revoke + tokens = self.project_tokens + + self._assertTokenNotRevoked(token_to_revoke) + for token in tokens: + self._assertTokenNotRevoked(token) + + event = self._revoke_by_grant(role_id=self.role_ids[0], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + + self._assertTokenRevoked(token_to_revoke) + for token in tokens: + self._assertTokenNotRevoked(token) + + self.removeEvent(event) + + self._assertTokenNotRevoked(token_to_revoke) + for token in tokens: + self._assertTokenNotRevoked(token) + + token_to_revoke['roles'] = [self.role_ids[0], + self.role_ids[1], + self.role_ids[2]] + + event = self._revoke_by_grant(role_id=self.role_ids[0], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + self._assertTokenRevoked(token_to_revoke) + self.removeEvent(event) + self._assertTokenNotRevoked(token_to_revoke) + + event = self._revoke_by_grant(role_id=self.role_ids[1], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + self._assertTokenRevoked(token_to_revoke) + self.removeEvent(event) + self._assertTokenNotRevoked(token_to_revoke) + + self._revoke_by_grant(role_id=self.role_ids[0], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + self._revoke_by_grant(role_id=self.role_ids[1], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + self._revoke_by_grant(role_id=self.role_ids[2], + user_id=self.user_ids[0], + project_id=self.project_ids[0]) + self._assertTokenRevoked(token_to_revoke) + + def _assertEmpty(self, collection): + return self.assertEqual(0, len(collection), "collection not empty") + + def _assertEventsMatchIteration(self, turn): + self.assertEqual(1, len(self.tree.revoke_map)) + self.assertEqual(turn + 1, len(self.tree.revoke_map + ['trust_id=*'] + ['consumer_id=*'] + ['access_token_id=*'])) + # two different functions add domain_ids, +1 for None + self.assertEqual(2 * turn + 1, len(self.tree.revoke_map + ['trust_id=*'] + ['consumer_id=*'] + ['access_token_id=*'] + ['expires_at=*'])) + # two different functions add project_ids, +1 for None + self.assertEqual(2 * turn + 1, len(self.tree.revoke_map + ['trust_id=*'] + ['consumer_id=*'] + ['access_token_id=*'] + ['expires_at=*'] + ['domain_id=*'])) + # 10 users added + self.assertEqual(turn, len(self.tree.revoke_map + ['trust_id=*'] + ['consumer_id=*'] + ['access_token_id=*'] + ['expires_at=*'] + ['domain_id=*'] + ['project_id=*'])) + + def test_cleanup(self): + events = self.events + self._assertEmpty(self.tree.revoke_map) + for i in range(0, 10): + events.append( + self._revoke_by_user(_new_id())) + events.append( + self._revoke_by_expiration(_new_id(), _future_time())) + events.append( + self._revoke_by_project_role_assignment(_new_id(), _new_id())) + events.append( + self._revoke_by_domain_role_assignment(_new_id(), _new_id())) + events.append( + self._revoke_by_domain_role_assignment(_new_id(), _new_id())) + events.append( + self._revoke_by_user_and_project(_new_id(), _new_id())) + self._assertEventsMatchIteration(i + 1) + + for event in self.events: + self.tree.remove_event(event) + self._assertEmpty(self.tree.revoke_map) diff --git a/keystone/tests/test_revoke_kvs.conf b/keystone/tests/test_revoke_kvs.conf new file mode 100644 index 0000000000..eb61411f89 --- /dev/null +++ b/keystone/tests/test_revoke_kvs.conf @@ -0,0 +1,6 @@ +[token] +provider = keystone.token.providers.pki.Provider +revoke_by_id = False + +[revoke] +driver = keystone.contrib.revoke.backends.kvs.Revoke \ No newline at end of file diff --git a/keystone/tests/test_revoke_sql.conf b/keystone/tests/test_revoke_sql.conf new file mode 100644 index 0000000000..662c60a43b --- /dev/null +++ b/keystone/tests/test_revoke_sql.conf @@ -0,0 +1,6 @@ +[token] +provider = keystone.token.providers.pki.Provider +revoke_by_id = False + +[revoke] +driver = keystone.contrib.revoke.backends.sql.Revoke \ No newline at end of file diff --git a/keystone/tests/test_sql_migrate_extensions.py b/keystone/tests/test_sql_migrate_extensions.py index 1e2f19a67c..a537947d80 100644 --- a/keystone/tests/test_sql_migrate_extensions.py +++ b/keystone/tests/test_sql_migrate_extensions.py @@ -36,6 +36,7 @@ from keystone.contrib import endpoint_filter from keystone.contrib import example from keystone.contrib import federation from keystone.contrib import oauth1 +from keystone.contrib import revoke from keystone.tests import test_sql_upgrade @@ -180,3 +181,27 @@ class FederationExtension(test_sql_upgrade.SqlMigrateBase): self.assertTableDoesNotExist(self.identity_provider) self.assertTableDoesNotExist(self.federation_protocol) self.assertTableDoesNotExist(self.mapping) + + +_REVOKE_COLUMN_NAMES = ['id', 'domain_id', 'project_id', 'user_id', 'role_id', + 'trust_id', 'consumer_id', 'access_token_id', + 'issued_before', 'expires_at', 'revoked_at'] + + +class RevokeExtension(test_sql_upgrade.SqlMigrateBase): + + def repo_package(self): + return revoke + + def test_upgrade(self): + self.assertTableDoesNotExist('revocation_event') + self.upgrade(1, repository=self.repo_path) + self.assertTableColumns('revocation_event', + _REVOKE_COLUMN_NAMES) + + def test_downgrade(self): + self.upgrade(1, repository=self.repo_path) + self.assertTableColumns('revocation_event', + _REVOKE_COLUMN_NAMES) + self.downgrade(0, repository=self.repo_path) + self.assertTableDoesNotExist('revocation_event') diff --git a/keystone/tests/test_token_provider.py b/keystone/tests/test_token_provider.py index 6f1593e2d5..0e27538aba 100644 --- a/keystone/tests/test_token_provider.py +++ b/keystone/tests/test_token_provider.py @@ -678,6 +678,7 @@ SAMPLE_V2_TOKEN_EXPIRED = { def create_v3_token(): return { "token": { + 'methods': [], "expires_at": timeutils.isotime(CURRENT_DATE + FUTURE_DELTA), "issued_at": "2013-05-21T00:02:43.941473Z", } diff --git a/keystone/tests/test_v3_auth.py b/keystone/tests/test_v3_auth.py index ebee13ec10..c27155fdd6 100644 --- a/keystone/tests/test_v3_auth.py +++ b/keystone/tests/test_v3_auth.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime import json import uuid @@ -20,6 +21,7 @@ from keystoneclient.common import cms from keystone import auth from keystone import config from keystone import exception +from keystone.openstack.common import timeutils from keystone import tests from keystone.tests import test_v3 @@ -28,7 +30,7 @@ CONF = config.CONF class TestAuthInfo(test_v3.RestfulTestCase): - # TDOD(henry-nash) These tests are somewhat inefficient, since by + # TODO(henry-nash) These tests are somewhat inefficient, since by # using the test_v3.RestfulTestCase class to gain access to the auth # building helper functions, they cause backend databases and fixtures # to be loaded unnecessarily. Separating out the helper functions from @@ -93,14 +95,13 @@ class TestAuthInfo(test_v3.RestfulTestCase): method_name) -class TestPKITokenAPIs(test_v3.RestfulTestCase): - def config_files(self): - conf_files = super(TestPKITokenAPIs, self).config_files() - conf_files.append(tests.dirs.tests('test_pki_token_provider.conf')) - return conf_files - - def setUp(self): - super(TestPKITokenAPIs, self).setUp() +class TokenAPITests(object): + # Why is this not just setUP? Because TokenAPITests is not a test class + # itself. If TokenAPITests became a subclass of the testcase, it would get + # called by the enumerate-tests-in-file code. The way the functions get + # resolved in Python for multiple inheritance means that a setUp in this + # would get skipped by the testrunner. + def doSetUp(self): auth_data = self.build_authentication_request( username=self.user['name'], user_domain_id=self.domain_id, @@ -376,21 +377,28 @@ class TestPKITokenAPIs(test_v3.RestfulTestCase): r = self.get('/auth/tokens?nocatalog', headers=headers) self.assertValidProjectScopedTokenResponse(r, require_catalog=False) - def test_revoke_token(self): - headers = {'X-Subject-Token': self.get_scoped_token()} - self.delete('/auth/tokens', headers=headers, expected_status=204) - self.head('/auth/tokens', headers=headers, expected_status=404) - # make sure we have a CRL - r = self.get('/auth/tokens/OS-PKI/revoked') - self.assertIn('signed', r.result) + +class TestPKITokenAPIs(test_v3.RestfulTestCase, TokenAPITests): + def config_files(self): + conf_files = super(TestPKITokenAPIs, self).config_files() + conf_files.append(tests.dirs.tests('test_pki_token_provider.conf')) + return conf_files + + def setUp(self): + super(TestPKITokenAPIs, self).setUp() + self.doSetUp() -class TestUUIDTokenAPIs(TestPKITokenAPIs): +class TestUUIDTokenAPIs(test_v3.RestfulTestCase, TokenAPITests): def config_files(self): conf_files = super(TestUUIDTokenAPIs, self).config_files() conf_files.append(tests.dirs.tests('test_uuid_token_provider.conf')) return conf_files + def setUp(self): + super(TestUUIDTokenAPIs, self).setUp() + self.doSetUp() + def test_v3_token_id(self): auth_data = self.build_authentication_request( user_id=self.user['id'], @@ -553,9 +561,15 @@ class TestTokenRevokeSelfAndAdmin(test_v3.RestfulTestCase): token=adminB_token) -class TestTokenRevoking(test_v3.RestfulTestCase): +class TestTokenRevokeById(test_v3.RestfulTestCase): """Test token revocation on the v3 Identity API.""" + def config_files(self): + conf_files = super(TestTokenRevokeById, self).config_files() + conf_files.append(tests.dirs.tests( + 'test_revoke_kvs.conf')) + return conf_files + def setUp(self): """Setup for Token Revoking Test Cases. @@ -579,7 +593,7 @@ class TestTokenRevoking(test_v3.RestfulTestCase): - User1 has role2 assigned to domainA """ - super(TestTokenRevoking, self).setUp() + super(TestTokenRevokeById, self).setUp() # Start by creating a couple of domains and projects self.domainA = self.new_domain_ref() @@ -721,46 +735,16 @@ class TestTokenRevoking(test_v3.RestfulTestCase): headers={'X-Subject-Token': token}, expected_status=404) - def test_deleting_role_revokes_token(self): - """Test deleting a role revokes token. - - Test Plan: - - - Add some additional test data, namely: - - A third project (project C) - - Three additional users - user4 owned by domainB and user5 and 6 - owned by domainA (different domain ownership should not affect - the test results, just provided to broaden test coverage) - - User5 is a member of group1 - - Group1 gets an additional assignment - role1 on projectB as - well as its existing role1 on projectA - - User4 has role2 on Project C - - User6 has role1 on projectA and domainA - - This allows us to create 5 tokens by virtue of different types of - role assignment: - - user1, scoped to ProjectA by virtue of user role1 assignment - - user5, scoped to ProjectB by virtue of group role1 assignment - - user4, scoped to ProjectC by virtue of user role2 assignment - - user6, scoped to ProjectA by virtue of user role1 assignment - - user6, scoped to DomainA by virtue of user role1 assignment - - role1 is then deleted - - Check the tokens on Project A and B, and DomainA are revoked, - but not the one for Project C - - """ - # Add the additional test data + def role_data_fixtures(self): self.projectC = self.new_project_ref(domain_id=self.domainA['id']) self.assignment_api.create_project(self.projectC['id'], self.projectC) - self.user4 = self.new_user_ref( - domain_id=self.domainB['id']) + self.user4 = self.new_user_ref(domain_id=self.domainB['id']) self.user4['password'] = uuid.uuid4().hex self.identity_api.create_user(self.user4['id'], self.user4) - self.user5 = self.new_user_ref( domain_id=self.domainA['id']) self.user5['password'] = uuid.uuid4().hex self.identity_api.create_user(self.user5['id'], self.user5) - self.user6 = self.new_user_ref( domain_id=self.domainA['id']) self.user6['password'] = uuid.uuid4().hex @@ -780,6 +764,34 @@ class TestTokenRevoking(test_v3.RestfulTestCase): user_id=self.user6['id'], domain_id=self.domainA['id']) + def test_deleting_role_revokes_token(self): + """Test deleting a role revokes token. + + Add some additional test data, namely: + - A third project (project C) + - Three additional users - user4 owned by domainB and user5 and 6 + owned by domainA (different domain ownership should not affect + the test results, just provided to broaden test coverage) + - User5 is a member of group1 + - Group1 gets an additional assignment - role1 on projectB as + well as its existing role1 on projectA + - User4 has role2 on Project C + - User6 has role1 on projectA and domainA + - This allows us to create 5 tokens by virtue of different types + of role assignment: + - user1, scoped to ProjectA by virtue of user role1 assignment + - user5, scoped to ProjectB by virtue of group role1 assignment + - user4, scoped to ProjectC by virtue of user role2 assignment + - user6, scoped to ProjectA by virtue of user role1 assignment + - user6, scoped to DomainA by virtue of user role1 assignment + - role1 is then deleted + - Check the tokens on Project A and B, and DomainA are revoked, + but not the one for Project C + + """ + + self.role_data_fixtures() + # Now we are ready to start issuing requests auth_data = self.build_authentication_request( user_id=self.user1['id'], @@ -1084,13 +1096,13 @@ class TestTokenRevoking(test_v3.RestfulTestCase): self.head('/auth/tokens', headers={'X-Subject-Token': token2}, expected_status=204) - # Adding user2 to a group should invalidate token + # Adding user2 to a group should not invalidate token self.put('/groups/%(group_id)s/users/%(user_id)s' % { 'group_id': self.group2['id'], 'user_id': self.user2['id']}) self.head('/auth/tokens', headers={'X-Subject-Token': token2}, - expected_status=404) + expected_status=204) def test_removing_role_assignment_does_not_affect_other_users(self): """Revoking a role from one user should not affect other users.""" @@ -1164,6 +1176,195 @@ class TestTokenRevoking(test_v3.RestfulTestCase): self.head(role_path, expected_status=404) +class TestTokenRevokeApi(TestTokenRevokeById): + EXTENSION_NAME = 'revoke' + EXTENSION_TO_ADD = 'revoke_extension' + + """Test token revocation on the v3 Identity API.""" + def config_files(self): + conf_files = super(TestTokenRevokeApi, self).config_files() + conf_files.append(tests.dirs.tests( + 'test_revoke_kvs.conf')) + return conf_files + + def assertValidDeletedProjectResponse(self, events_response, project_id): + events = events_response['events'] + self.assertEqual(1, len(events)) + self.assertEqual(project_id, events[0]['project_id']) + self.assertIsNotNone(events[0]['issued_before']) + self.assertIsNotNone(events_response['links']) + del (events_response['events'][0]['issued_before']) + del (events_response['links']) + expected_response = {'events': [{'project_id': project_id}]} + self.assertEqual(expected_response, events_response) + + def assertDomainInList(self, events_response, domain_id): + events = events_response['events'] + self.assertEqual(1, len(events)) + self.assertEqual(domain_id, events[0]['domain_id']) + self.assertIsNotNone(events[0]['issued_before']) + self.assertIsNotNone(events_response['links']) + del (events_response['events'][0]['issued_before']) + del (events_response['links']) + expected_response = {'events': [{'domain_id': domain_id}]} + self.assertEqual(expected_response, events_response) + + def assertValidRevokedTokenResponse(self, events_response, user_id): + events = events_response['events'] + self.assertEqual(1, len(events)) + self.assertEqual(user_id, events[0]['user_id']) + self.assertIsNotNone(events[0]['expires_at']) + self.assertIsNotNone(events[0]['issued_before']) + self.assertIsNotNone(events_response['links']) + del (events_response['events'][0]['expires_at']) + del (events_response['events'][0]['issued_before']) + del (events_response['links']) + expected_response = {'events': [{'user_id': user_id}]} + self.assertEqual(expected_response, events_response) + + def test_revoke_token(self): + scoped_token = self.get_scoped_token() + headers = {'X-Subject-Token': scoped_token} + self.head('/auth/tokens', headers=headers, expected_status=204) + self.delete('/auth/tokens', headers=headers, expected_status=204) + self.head('/auth/tokens', headers=headers, expected_status=404) + events_response = self.get('/OS-REVOKE/events', + expected_status=200).json_body + self.assertValidRevokedTokenResponse(events_response, self.user['id']) + + def get_v2_token(self): + body = { + 'auth': { + 'passwordCredentials': { + 'username': self.default_domain_user['name'], + 'password': self.default_domain_user['password'], + }, + }, + } + r = self.admin_request(method='POST', path='/v2.0/tokens', body=body) + return r.json_body['access']['token']['id'] + + def test_revoke_v2_token(self): + token = self.get_v2_token() + headers = {'X-Subject-Token': token} + self.head('/auth/tokens', headers=headers, expected_status=204) + self.delete('/auth/tokens', headers=headers, expected_status=204) + self.head('/auth/tokens', headers=headers, expected_status=404) + events_response = self.get('/OS-REVOKE/events', + expected_status=200).json_body + + self.assertValidRevokedTokenResponse(events_response, + self.default_domain_user['id']) + + def test_revoke_by_id_false_410(self): + self.get('/auth/tokens/OS-PKI/revoked', expected_status=410) + + def test_list_delete_project_shows_in_event_list(self): + self.role_data_fixtures() + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body['events'] + self.assertEqual([], events) + self.delete( + '/projects/%(project_id)s' % {'project_id': self.projectA['id']}) + events_response = self.get('/OS-REVOKE/events', + expected_status=200).json_body + + self.assertValidDeletedProjectResponse(events_response, + self.projectA['id']) + + def test_disable_domain_shows_in_event_list(self): + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body['events'] + self.assertEqual([], events) + disable_body = {'domain': {'enabled': False}} + self.patch( + '/domains/%(project_id)s' % {'project_id': self.domainA['id']}, + body=disable_body) + + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body + + self.assertDomainInList(events, self.domainA['id']) + + def assertUserAndExpiryInList(self, events, user_id, expires_at): + found = False + for e in events: + if e['user_id'] == user_id and e['expires_at'] == expires_at: + found = True + self.assertTrue(found, + 'event with correct user_id %s and expires_at value ' + 'not in list' % user_id) + + def test_list_delete_token_shows_in_event_list(self): + self.role_data_fixtures() + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body['events'] + self.assertEqual([], events) + + scoped_token = self.get_scoped_token() + headers = {'X-Subject-Token': scoped_token} + auth_req = self.build_authentication_request(token=scoped_token) + response = self.post('/auth/tokens', body=auth_req) + token2 = response.json_body['token'] + headers2 = {'X-Subject-Token': response.headers['X-Subject-Token']} + + response = self.post('/auth/tokens', body=auth_req) + response.json_body['token'] + headers3 = {'X-Subject-Token': response.headers['X-Subject-Token']} + + scoped_token = self.get_scoped_token() + headers_unrevoked = {'X-Subject-Token': scoped_token} + + self.head('/auth/tokens', headers=headers, expected_status=204) + self.head('/auth/tokens', headers=headers2, expected_status=204) + self.head('/auth/tokens', headers=headers3, expected_status=204) + self.head('/auth/tokens', headers=headers_unrevoked, + expected_status=204) + + self.delete('/auth/tokens', headers=headers, expected_status=204) + # NOTE(ayoung): not deleting token3, as it should be deleted + # by previous + events_response = self.get('/OS-REVOKE/events', + expected_status=200).json_body + events = events_response['events'] + self.assertEqual(1, len(events)) + self.assertUserAndExpiryInList(events, + token2['user']['id'], + token2['expires_at']) + self.assertValidRevokedTokenResponse(events_response, self.user['id']) + self.head('/auth/tokens', headers=headers, expected_status=404) + self.head('/auth/tokens', headers=headers2, expected_status=404) + self.head('/auth/tokens', headers=headers3, expected_status=404) + self.head('/auth/tokens', headers=headers_unrevoked, + expected_status=204) + + def test_list_with_filter(self): + + self.role_data_fixtures() + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body['events'] + self.assertEqual(0, len(events)) + + scoped_token = self.get_scoped_token() + headers = {'X-Subject-Token': scoped_token} + auth = self.build_authentication_request(token=scoped_token) + response = self.post('/auth/tokens', body=auth) + headers2 = {'X-Subject-Token': response.headers['X-Subject-Token']} + self.delete('/auth/tokens', headers=headers, expected_status=204) + self.delete('/auth/tokens', headers=headers2, expected_status=204) + + events = self.get('/OS-REVOKE/events', + expected_status=200).json_body['events'] + + self.assertEqual(2, len(events)) + future = timeutils.isotime(timeutils.utcnow() + + datetime.timedelta(seconds=1000)) + + events = self.get('/OS-REVOKE/events?since=%s' % (future), + expected_status=200).json_body['events'] + self.assertEqual(0, len(events)) + + class TestAuthExternalDisabled(test_v3.RestfulTestCase): def config_files(self): cfg_list = self._config_file_list[:] @@ -1233,7 +1434,7 @@ class TestAuthExternalLegacyDomain(test_v3.RestfulTestCase): # '@' character. user = {'name': 'myname@mydivision'} self.identity_api.update_user(self.user['id'], user) - remote_user = '%s@%s' % (user["name"], self.domain['name']) + remote_user = '%s@%s' % (user['name'], self.domain['name']) context, auth_info, auth_context = self.build_external_auth_request( remote_user) @@ -1284,7 +1485,7 @@ class TestAuthExternalDomain(test_v3.RestfulTestCase): # '@' character. user = {'name': 'myname@mydivision'} self.identity_api.update_user(self.user['id'], user) - remote_user = user["name"] + remote_user = user['name'] context, auth_info, auth_context = self.build_external_auth_request( remote_user, remote_domain=remote_domain) @@ -2015,6 +2216,15 @@ class TestTrustOptional(test_v3.RestfulTestCase): class TestTrustAuth(TestAuthInfo): + EXTENSION_NAME = 'revoke' + EXTENSION_TO_ADD = 'revoke_extension' + + def config_files(self): + conf_files = super(TestTrustAuth, self).config_files() + conf_files.append(tests.dirs.tests( + 'test_revoke_kvs.conf')) + return conf_files + def setUp(self): self.opt_in_group('trust', enabled=True) super(TestTrustAuth, self).setUp() @@ -2456,6 +2666,44 @@ class TestTrustAuth(TestAuthInfo): self.assertEqual(r.result['token']['project']['name'], self.project['name']) + def assertTrustTokensRevoked(self, trust_id): + revocation_response = self.get('/OS-REVOKE/events', + expected_status=200) + revocation_events = revocation_response.json_body['events'] + found = False + for event in revocation_events: + if event.get('OS-TRUST:trust_id') == trust_id: + found = True + self.assertTrue(found, 'event with trust_id %s not found in list' % + trust_id) + + def test_delete_trust_revokes_tokens(self): + ref = self.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + impersonation=False, + expires=dict(minutes=1), + role_ids=[self.role_id]) + del ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust = self.assertValidTrustResponse(r) + trust_id = trust['id'] + auth_data = self.build_authentication_request( + user_id=self.trustee_user['id'], + password=self.trustee_user['password'], + trust_id=trust_id) + r = self.post('/auth/tokens', body=auth_data) + self.assertValidProjectTrustScopedTokenResponse( + r, self.trustee_user) + trust_token = r.headers['X-Subject-Token'] + self.delete('/OS-TRUST/trusts/%(trust_id)s' % { + 'trust_id': trust_id}, + expected_status=204) + headers = {'X-Subject-Token': trust_token} + self.head('/auth/tokens', headers=headers, expected_status=404) + self.assertTrustTokensRevoked(trust_id) + def test_delete_trust(self): ref = self.new_trust_ref( trustor_user_id=self.user_id, diff --git a/keystone/tests/test_v3_os_revoke.py b/keystone/tests/test_v3_os_revoke.py new file mode 100644 index 0000000000..ed754f9adf --- /dev/null +++ b/keystone/tests/test_v3_os_revoke.py @@ -0,0 +1,116 @@ +# 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 datetime +import uuid + +from keystone.common import dependency +from keystone.contrib.revoke import model +from keystone.openstack.common import timeutils +from keystone.tests import test_v3 +from keystone import token + + +def _future_time_string(): + expire_delta = datetime.timedelta(seconds=1000) + future_time = timeutils.utcnow() + expire_delta + return timeutils.isotime(future_time) + + +@dependency.requires('revoke_api') +class OSRevokeTests(test_v3.RestfulTestCase): + EXTENSION_NAME = 'revoke' + EXTENSION_TO_ADD = 'revoke_extension' + + def test_get_empty_list(self): + resp = self.get('/OS-REVOKE/events') + self.assertEqual([], resp.json_body['events']) + + def _blank_event(self): + return {} + + # The two values will be the same with the exception of + # 'issued_before' which is set when the event is recorded. + def assertReporteEventMatchesRecorded(self, event, sample, before_time): + after_time = timeutils.utcnow() + event_issued_before = timeutils.normalize_time( + timeutils.parse_isotime(event['issued_before'])) + self.assertTrue(before_time < event_issued_before, + 'invalid event issued_before time; Too early') + self.assertTrue(event_issued_before < after_time, + 'invalid event issued_before time; too late') + del (event['issued_before']) + self.assertEqual(sample, event) + + def test_revoked_token_in_list(self): + user_id = uuid.uuid4().hex + expires_at = token.default_expire_time() + sample = self._blank_event() + sample['user_id'] = unicode(user_id) + sample['expires_at'] = unicode(timeutils.isotime(expires_at, + subsecond=True)) + before_time = timeutils.utcnow() + self.revoke_api.revoke_by_expiration(user_id, expires_at) + resp = self.get('/OS-REVOKE/events') + events = resp.json_body['events'] + self.assertEqual(len(events), 1) + self.assertReporteEventMatchesRecorded(events[0], sample, before_time) + + def test_disabled_project_in_list(self): + project_id = uuid.uuid4().hex + sample = dict() + sample['project_id'] = unicode(project_id) + before_time = timeutils.utcnow() + self.revoke_api.revoke( + model.RevokeEvent(project_id=project_id)) + + resp = self.get('/OS-REVOKE/events') + events = resp.json_body['events'] + self.assertEqual(len(events), 1) + self.assertReporteEventMatchesRecorded(events[0], sample, before_time) + + def test_disabled_domain_in_list(self): + domain_id = uuid.uuid4().hex + sample = dict() + sample['domain_id'] = unicode(domain_id) + before_time = timeutils.utcnow() + self.revoke_api.revoke( + model.RevokeEvent(domain_id=domain_id)) + + resp = self.get('/OS-REVOKE/events') + events = resp.json_body['events'] + self.assertEqual(len(events), 1) + self.assertReporteEventMatchesRecorded(events[0], sample, before_time) + + def test_list_since_invalid(self): + self.get('/OS-REVOKE/events?since=blah', expected_status=400) + + def test_list_since_valid(self): + resp = self.get('/OS-REVOKE/events?since=2013-02-27T18:30:59.999999Z') + events = resp.json_body['events'] + self.assertEqual(len(events), 0) + + def test_since_future_time_no_events(self): + domain_id = uuid.uuid4().hex + sample = dict() + sample['domain_id'] = unicode(domain_id) + + self.revoke_api.revoke( + model.RevokeEvent(domain_id=domain_id)) + + resp = self.get('/OS-REVOKE/events') + events = resp.json_body['events'] + self.assertEqual(len(events), 1) + + resp = self.get('/OS-REVOKE/events?since=%s' % _future_time_string()) + events = resp.json_body['events'] + self.assertEqual([], events) diff --git a/keystone/token/backends/kvs.py b/keystone/token/backends/kvs.py index 5eb4040d42..70e6824c82 100644 --- a/keystone/token/backends/kvs.py +++ b/keystone/token/backends/kvs.py @@ -299,6 +299,15 @@ class Token(token.Driver): def _list_tokens(self, user_id, tenant_id=None, trust_id=None, consumer_id=None): + # This function is used to generate the list of tokens that should be + # revoked when revoking by token identifiers. This approach will be + # deprecated soon, probably in the Juno release. Setting revoke_by_id + # to False indicates that this kind of recording should not be + # performed. In order to test the revocation events, tokens shouldn't + # be deleted from the backends. This check ensures that tokens are + # still recorded. + if not CONF.token.revoke_by_id: + return [] tokens = [] user_key = self._prefix_user_id(user_id) token_list = self._get_user_token_list_with_expiry(user_key) diff --git a/keystone/token/backends/sql.py b/keystone/token/backends/sql.py index 8597f7bc25..a32d52fae9 100644 --- a/keystone/token/backends/sql.py +++ b/keystone/token/backends/sql.py @@ -15,12 +15,16 @@ import copy from keystone.common import sql +from keystone import config from keystone import exception from keystone.openstack.common.db.sqlalchemy import session as db_session from keystone.openstack.common import timeutils from keystone import token +CONF = config.CONF + + class TokenModel(sql.ModelBase, sql.DictBase): __tablename__ = 'token' attributes = ['id', 'expires', 'user_id', 'trust_id'] @@ -164,6 +168,8 @@ class Token(token.Driver): def _list_tokens(self, user_id, tenant_id=None, trust_id=None, consumer_id=None): + if not CONF.token.revoke_by_id: + return [] if trust_id: return self._list_tokens_for_trust(trust_id) if consumer_id: diff --git a/keystone/token/controllers.py b/keystone/token/controllers.py index 7f765ea8c7..d8d42b7f85 100644 --- a/keystone/token/controllers.py +++ b/keystone/token/controllers.py @@ -391,6 +391,7 @@ class Auth(controller.V2Controller): Identical to ``validate_token``, except does not return a response. """ + # TODO(ayoung) validate against revocation API belongs_to = context['query_string'].get('belongsTo') self.token_provider_api.check_v2_token(token_id, belongs_to) @@ -405,6 +406,7 @@ class Auth(controller.V2Controller): """ belongs_to = context['query_string'].get('belongsTo') + # TODO(ayoung) validate against revocation API return self.token_provider_api.validate_v2_token(token_id, belongs_to) @controller.v2_deprecated @@ -412,11 +414,13 @@ class Auth(controller.V2Controller): """Delete a token, effectively invalidating it for authz.""" # TODO(termie): this stuff should probably be moved to middleware self.assert_admin(context) - self.token_api.delete_token(token_id) + self.token_provider_api.revoke_token(token_id) @controller.v2_deprecated @controller.protected() def revocation_list(self, context, auth=None): + if not CONF.token.revoke_by_id: + raise exception.Gone() tokens = self.token_api.list_revoked_tokens() for t in tokens: diff --git a/keystone/token/core.py b/keystone/token/core.py index 420b75f666..4bd2439d9a 100644 --- a/keystone/token/core.py +++ b/keystone/token/core.py @@ -164,6 +164,8 @@ class Manager(manager.Manager): return ret def delete_token(self, token_id): + if not CONF.token.revoke_by_id: + return unique_id = self.unique_id(token_id) self.driver.delete_token(unique_id) self._invalidate_individual_token_cache(unique_id) @@ -171,6 +173,8 @@ class Manager(manager.Manager): def delete_tokens(self, user_id, tenant_id=None, trust_id=None, consumer_id=None): + if not CONF.token.revoke_by_id: + return token_list = self.driver._list_tokens(user_id, tenant_id, trust_id, consumer_id) self.driver.delete_tokens(user_id, tenant_id, trust_id, consumer_id) @@ -192,6 +196,8 @@ class Manager(manager.Manager): def delete_tokens_for_domain(self, domain_id): """Delete all tokens for a given domain.""" + if not CONF.token.revoke_by_id: + return projects = self.assignment_api.list_projects() for project in projects: if project['domain_id'] == domain_id: @@ -207,6 +213,8 @@ class Manager(manager.Manager): revocations in a single call instead of needing to explicitly handle trusts in the caller's logic. """ + if not CONF.token.revoke_by_id: + return self.delete_tokens(user_id, tenant_id=project_id) for trust in self.trust_api.list_trusts_for_trustee(user_id): # Ensure we revoke tokens associated to the trust / project @@ -234,6 +242,8 @@ class Manager(manager.Manager): :param user_ids: list of user identifiers :param project_id: optional project identifier """ + if not CONF.token.revoke_by_id: + return for user_id in user_ids: self.delete_tokens_for_user(user_id, project_id=project_id) @@ -353,6 +363,8 @@ class Driver(object): :raises: keystone.exception.TokenNotFound """ + if not CONF.token.revoke_by_id: + return token_list = self._list_tokens(user_id, tenant_id=tenant_id, trust_id=trust_id, diff --git a/keystone/token/provider.py b/keystone/token/provider.py index 81f7125d8b..39452b5fe1 100644 --- a/keystone/token/provider.py +++ b/keystone/token/provider.py @@ -22,6 +22,8 @@ from keystone.common import cache from keystone.common import dependency from keystone.common import manager from keystone import config +from keystone.contrib.revoke import model as revoke_model + from keystone import exception from keystone.openstack.common import log from keystone.openstack.common import timeutils @@ -50,6 +52,7 @@ class UnsupportedTokenVersionException(Exception): @dependency.requires('token_api') +@dependency.optional('revoke_api') @dependency.provider('token_provider_api') class Manager(manager.Manager): """Default pivot point for the token provider backend. @@ -115,15 +118,43 @@ class Manager(manager.Manager): self._is_valid_token(token) return token + def check_revocation_v2(self, token): + try: + token_data = token['access'] + except KeyError: + raise exception.TokenNotFound(_('Failed to validate token')) + + token_values = revoke_model.build_token_values_v2( + token_data, CONF.identity.default_domain_id) + if self.revoke_api is not None: + self.revoke_api.check_token(token_values) + def validate_v2_token(self, token_id, belongs_to=None): unique_id = self.token_api.unique_id(token_id) # NOTE(morganfainberg): Ensure we never use the long-form token_id # (PKI) as part of the cache_key. token = self._validate_v2_token(unique_id) + self.check_revocation_v2(token) self._token_belongs_to(token, belongs_to) self._is_valid_token(token) return token + def check_revocation_v3(self, token): + try: + token_data = token['token'] + except KeyError: + raise exception.TokenNotFound(_('Failed to validate token')) + token_values = revoke_model.build_token_values(token_data) + if self.revoke_api is not None: + self.revoke_api.check_token(token_values) + + def check_revocation(self, token): + version = self.driver.get_token_version(token) + if version == V2: + return self.check_revocation_v2(token) + else: + return self.check_revocation_v3(token) + def validate_v3_token(self, token_id): unique_id = self.token_api.unique_id(token_id) # NOTE(morganfainberg): Ensure we never use the long-form token_id @@ -187,14 +218,17 @@ class Manager(manager.Manager): expires_at = token_data['token']['expires'] expiry = timeutils.normalize_time( timeutils.parse_isotime(expires_at)) - if current_time < expiry: - # Token is has not expired and has not been revoked. - return None except Exception: LOG.exception(_('Unexpected error or malformed token determining ' 'token expiry: %s'), token) + raise exception.TokenNotFound(_('Failed to validate token')) - raise exception.TokenNotFound(_("The token is malformed or expired.")) + if current_time < expiry: + self.check_revocation(token) + # Token has not expired and has not been revoked. + return None + else: + raise exception.TokenNotFound(_('Failed to validate token')) def _token_belongs_to(self, token, belongs_to): """Check if the token belongs to the right tenant. diff --git a/keystone/token/providers/common.py b/keystone/token/providers/common.py index 910f08d199..03a03e5fa4 100644 --- a/keystone/token/providers/common.py +++ b/keystone/token/providers/common.py @@ -356,7 +356,7 @@ class V3TokenDataHelper(object): @dependency.optional('oauth_api') @dependency.requires('assignment_api', 'catalog_api', 'identity_api', - 'token_api', 'trust_api') + 'revoke_api', 'token_api', 'trust_api') class BaseProvider(provider.Provider): def __init__(self, *args, **kwargs): super(BaseProvider, self).__init__(*args, **kwargs) @@ -525,7 +525,19 @@ class BaseProvider(provider.Provider): return token_ref def revoke_token(self, token_id): - self.token_api.delete_token(token_id=token_id) + token = self.token_api.get_token(token_id) + if self.revoke_api: + version = self.get_token_version(token) + if version == provider.V3: + user_id = token['user']['id'] + expires_at = token['expires'] + elif version == provider.V2: + user_id = token['user_id'] + expires_at = token['expires'] + self.revoke_api.revoke_by_expiration(user_id, expires_at) + + if CONF.token.revoke_by_id: + self.token_api.delete_token(token_id=token_id) def _assert_default_domain(self, token_ref): """Make sure we are operating on default domain only.""" @@ -616,9 +628,8 @@ class BaseProvider(provider.Provider): token_ref = self._verify_token(token_id) token_data = self._validate_v3_token_ref(token_ref) return token_data - except (exception.ValidationError, - exception.UserNotFound): - LOG.exception(_('Failed to validate token')) + except (exception.ValidationError, exception.UserNotFound): + raise exception.TokenNotFound(token_id) def _validate_v3_token_ref(self, token_ref): # FIXME(gyee): performance or correctness? Should we return the