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
This commit is contained in:
Adam Young 2014-01-21 15:06:48 -05:00
parent 4bec42e0d8
commit 2e51473138
46 changed files with 2108 additions and 132 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
[token]
provider = keystone.token.providers.pki.Provider
revoke_by_id = False
[revoke]
driver = keystone.contrib.revoke.backends.kvs.Revoke

View File

@ -0,0 +1,6 @@
[token]
provider = keystone.token.providers.pki.Provider
revoke_by_id = False
[revoke]
driver = keystone.contrib.revoke.backends.sql.Revoke

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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