summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/source/configuration.rst8
-rw-r--r--doc/source/extensions/revoke-configuration.rst41
-rw-r--r--etc/keystone-paste.ini3
-rw-r--r--etc/keystone.conf.sample25
-rw-r--r--etc/policy.json3
-rw-r--r--etc/policy.v3cloudsample.json3
-rw-r--r--keystone/assignment/core.py27
-rw-r--r--keystone/auth/controllers.py2
-rw-r--r--keystone/cli.py46
-rw-r--r--keystone/common/config.py22
-rw-r--r--keystone/common/controller.py7
-rw-r--r--keystone/common/sql/migration_helpers.py48
-rw-r--r--keystone/contrib/revoke/__init__.py13
-rw-r--r--keystone/contrib/revoke/backends/__init__.py0
-rw-r--r--keystone/contrib/revoke/backends/kvs.py65
-rw-r--r--keystone/contrib/revoke/backends/sql.py113
-rw-r--r--keystone/contrib/revoke/controllers.py41
-rw-r--r--keystone/contrib/revoke/core.py220
-rw-r--r--keystone/contrib/revoke/migrate_repo/__init__.py0
-rw-r--r--keystone/contrib/revoke/migrate_repo/migrate.cfg25
-rw-r--r--keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py47
-rw-r--r--keystone/contrib/revoke/migrate_repo/versions/__init__.py0
-rw-r--r--keystone/contrib/revoke/model.py290
-rw-r--r--keystone/contrib/revoke/routers.py26
-rw-r--r--keystone/exception.py7
-rw-r--r--keystone/identity/core.py29
-rw-r--r--keystone/middleware/core.py8
-rw-r--r--keystone/service.py2
-rw-r--r--keystone/tests/_sql_livetest.py25
-rw-r--r--keystone/tests/backend_sql.conf3
-rw-r--r--keystone/tests/core.py19
-rw-r--r--keystone/tests/test_content_types.py56
-rw-r--r--keystone/tests/test_overrides.conf3
-rw-r--r--keystone/tests/test_revoke.py405
-rw-r--r--keystone/tests/test_revoke_kvs.conf6
-rw-r--r--keystone/tests/test_revoke_sql.conf6
-rw-r--r--keystone/tests/test_sql_migrate_extensions.py25
-rw-r--r--keystone/tests/test_token_provider.py1
-rw-r--r--keystone/tests/test_v3_auth.py358
-rw-r--r--keystone/tests/test_v3_os_revoke.py116
-rw-r--r--keystone/token/backends/kvs.py9
-rw-r--r--keystone/token/backends/sql.py6
-rw-r--r--keystone/token/controllers.py6
-rw-r--r--keystone/token/core.py12
-rw-r--r--keystone/token/provider.py42
-rw-r--r--keystone/token/providers/common.py21
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