diff options
46 files changed, 2108 insertions, 132 deletions
diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 4ac276f44..fd3c09040 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 000000000..fee0d1ced --- /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 6e0298464..71de7d40a 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 b93a274bf..c45c84bb3 100644 --- a/etc/keystone.conf.sample +++ b/etc/keystone.conf.sample @@ -1083,6 +1083,22 @@ #list_limit=<None> +[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=<None> +# 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 fbb83a9dc..9c7e646ef 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 a78fcc12a..e481eddd6 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 325eb2a97..27e8f0309 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 78acced74..0289f302a 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 c0b471b9d..a9decbde0 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 3e3b037d5..71d93e87e 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 04c1831fe..b62453094 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 df98bdb39..ba150d802 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 000000000..d78fcfaf8 --- /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 000000000..e69de29bb --- /dev/null +++ b/keystone/contrib/revoke/backends/__init__.py diff --git a/keystone/contrib/revoke/backends/kvs.py b/keystone/contrib/revoke/backends/kvs.py new file mode 100644 index 000000000..de87f1b8f --- /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 000000000..bbf729fc6 --- /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 000000000..67b4b1db5 --- /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 000000000..b02f34dc9 --- /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 000000000..e69de29bb --- /dev/null +++ b/keystone/contrib/revoke/migrate_repo/__init__.py diff --git a/keystone/contrib/revoke/migrate_repo/migrate.cfg b/keystone/contrib/revoke/migrate_repo/migrate.cfg new file mode 100644 index 000000000..0e61bcaa2 --- /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 000000000..7927ce0c5 --- /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 000000000..e69de29bb --- /dev/null +++ b/keystone/contrib/revoke/migrate_repo/versions/__init__.py diff --git a/keystone/contrib/revoke/model.py b/keystone/contrib/revoke/model.py new file mode 100644 index 000000000..a4e85448d --- /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 000000000..5f65ecc4a --- /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 227a301be..d99bcaee6 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 44807c754..42ca86d5d 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 4fa50ac7c..dbd9933aa 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 bb2dd0baa..ffc7feb6d 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 ad17d3938..c6577ed9a 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 b275b2e93..dd2d7d61c 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 a76231563..ead9b0cd8 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 f99b79091..238aecbba 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 fadc7ce23..c44363c21 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 000000000..9203ca48f --- /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 000000000..eb61411f8 --- /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 000000000..662c60a43 --- /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 1e2f19a67..a537947d8 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 6f1593e2d..0e27538ab 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 ebee13ec1..c27155fdd 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 000000000..ed754f9ad --- /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 5eb4040d4..70e6824c8 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 8597f7bc2..a32d52fae 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 7f765ea8c..d8d42b7f8 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 420b75f66..4bd2439d9 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 81f7125d8..39452b5fe 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 910f08d19..03a03e5fa 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 |