Token Revocation Extension

Base API for reporting revocation events.

The KVS Backend uses the Dogpile backed KVS stores.

Modifies the places that were directly deleting tokens to also generate
revocation events.

Where possible the revocations are triggered by listening to the notifications.
Some places, the callers have been modified instead.  This is usually due to
the need to iterate through a collection, such as users in a group.

Adds a config file option to disable the existing mechanisms that support
revoking a token by that token's id: revoke_by_id. This flag is necessary
to test that the revocation mechanism is working as defined, but will also
be part of the phased removal of the older mechanisms. TokenRevoke tests
have been extended to test both with and without revoke-by-id enabled.

Note: The links aren't populated in the list_events response.

SQL Backend for Revocation Events

Initializes the SQL Database for the revocation backend.
This patch refactors the sql migration call from the CLI
so that the test framework can use it as well. The sql
backend for revcations is exercized by test_notifications
and must be properly initialized.

Revoke By Search Tree

Co-Authored-By: Yuriy Taraday (Yoriksar)

create a set of nested maps for the events. Look up revocation by
traversing down the tree.

Blueprint: revocation-events

Change-Id: If76c8cd5d01a5b991c58a4d1a9d534b2a3da875a
changes/08/55908/82
Adam Young 9 years ago
parent 4bec42e0d8
commit 2e51473138
  1. 8
      doc/source/configuration.rst
  2. 41
      doc/source/extensions/revoke-configuration.rst
  3. 3
      etc/keystone-paste.ini
  4. 25
      etc/keystone.conf.sample
  5. 3
      etc/policy.json
  6. 3
      etc/policy.v3cloudsample.json
  7. 27
      keystone/assignment/core.py
  8. 2
      keystone/auth/controllers.py
  9. 46
      keystone/cli.py
  10. 22
      keystone/common/config.py
  11. 7
      keystone/common/controller.py
  12. 48
      keystone/common/sql/migration_helpers.py
  13. 13
      keystone/contrib/revoke/__init__.py
  14. 0
      keystone/contrib/revoke/backends/__init__.py
  15. 65
      keystone/contrib/revoke/backends/kvs.py
  16. 113
      keystone/contrib/revoke/backends/sql.py
  17. 41
      keystone/contrib/revoke/controllers.py
  18. 220
      keystone/contrib/revoke/core.py
  19. 0
      keystone/contrib/revoke/migrate_repo/__init__.py
  20. 25
      keystone/contrib/revoke/migrate_repo/migrate.cfg
  21. 47
      keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py
  22. 0
      keystone/contrib/revoke/migrate_repo/versions/__init__.py
  23. 290
      keystone/contrib/revoke/model.py
  24. 26
      keystone/contrib/revoke/routers.py
  25. 7
      keystone/exception.py
  26. 29
      keystone/identity/core.py
  27. 8
      keystone/middleware/core.py
  28. 2
      keystone/service.py
  29. 25
      keystone/tests/_sql_livetest.py
  30. 3
      keystone/tests/backend_sql.conf
  31. 19
      keystone/tests/core.py
  32. 56
      keystone/tests/test_content_types.py
  33. 3
      keystone/tests/test_overrides.conf
  34. 405
      keystone/tests/test_revoke.py
  35. 6
      keystone/tests/test_revoke_kvs.conf
  36. 6
      keystone/tests/test_revoke_sql.conf
  37. 25
      keystone/tests/test_sql_migrate_extensions.py
  38. 1
      keystone/tests/test_token_provider.py
  39. 358
      keystone/tests/test_v3_auth.py
  40. 116
      keystone/tests/test_v3_os_revoke.py
  41. 9
      keystone/token/backends/kvs.py
  42. 6
      keystone/token/backends/sql.py
  43. 6
      keystone/token/controllers.py
  44. 12
      keystone/token/core.py
  45. 42
      keystone/token/provider.py
  46. 21
      keystone/token/providers/common.py

@ -856,6 +856,14 @@ Federation
extensions/federation-configuration.rst
Revocation Events
------------------
.. toctree::
:maxdepth: 1
extensions/revoke-configuration.rst
.. _`prepare your deployment`:
Preparing your deployment

@ -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

@ -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

@ -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]

@ -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": ""
}

@ -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": ""
}

@ -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)

@ -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:

@ -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):

@ -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 '

@ -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'))

@ -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)

@ -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

@ -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)

@ -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)

@ -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

@ -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()

@ -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

@ -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)

@ -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

@ -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']))

@ -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.")

@ -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