Revoke by Audit Id / Audit Id Chain instead of expires

Instead of using the expiry of the token which can collide (is non
unique in some/many cases) use the new Audit ID for the tokens when
revoking a single token via the token revocation events.

Support for revoking by the audit_chain_id has been added to the
token provider, however, the REST API has not been updated to
accept an argument to revoke the chain. Support for revoking
the entire chain is in place to allow Keystone to internally
revoke an entire chain in certain circumstances. Exposing the
ability to revoke the entire chain via the REST API may occur
based upon further design discussions.

Change-Id: I840355ccd9bcfcd88aa139184731c056808c2c8f
bp: non-persistent-tokens
Closes-Bug: 1292283
This commit is contained in:
Morgan Fainberg 2014-08-25 20:17:47 -07:00
parent 66ec5d59e1
commit ea185a25a2
9 changed files with 348 additions and 65 deletions

View File

@ -34,6 +34,8 @@ class RevocationEvent(sql.ModelBase, sql.ModelDictMixin):
issued_before = sql.Column(sql.DateTime(), nullable=False)
expires_at = sql.Column(sql.DateTime())
revoked_at = sql.Column(sql.DateTime(), nullable=False)
audit_id = sql.Column(sql.String(32))
audit_chain_id = sql.Column(sql.String(32))
class Revoke(revoke.Driver):

View File

@ -26,6 +26,7 @@ from keystone import exception
from keystone.i18n import _
from keystone import notifications
from keystone.openstack.common import log
from keystone.openstack.common import versionutils
CONF = config.CONF
@ -128,6 +129,8 @@ class Manager(manager.Manager):
def revoke_by_user(self, user_id):
return self.revoke(model.RevokeEvent(user_id=user_id))
@versionutils.deprecated(as_of=versionutils.deprecated.JUNO,
remove_in=0)
def revoke_by_expiration(self, user_id, expires_at,
domain_id=None, project_id=None):
@ -144,6 +147,15 @@ class Manager(manager.Manager):
domain_id=domain_id,
project_id=project_id))
def revoke_by_audit_id(self, audit_id):
self.revoke(model.RevokeEvent(audit_id=audit_id))
def revoke_by_audit_chain_id(self, audit_chain_id, project_id=None,
domain_id=None):
self.revoke(model.RevokeEvent(audit_chain_id=audit_chain_id,
domain_id=domain_id,
project_id=project_id))
def revoke_by_grant(self, role_id, user_id=None,
domain_id=None, project_id=None):
self.revoke(

View File

@ -0,0 +1,37 @@
# 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
_TABLE_NAME = 'revocation_event'
def upgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
event_table = sql.Table(_TABLE_NAME, meta, autoload=True)
audit_id_column = sql.Column('audit_id', sql.String(32), nullable=True)
audit_chain_column = sql.Column('audit_chain_id', sql.String(32),
nullable=True)
event_table.create_column(audit_id_column)
event_table.create_column(audit_chain_column)
def downgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
event_table = sql.Table(_TABLE_NAME, meta, autoload=True)
event_table.drop_column('audit_id')
event_table.drop_column('audit_chain_id')

View File

@ -18,6 +18,8 @@ from oslo.utils import timeutils
_NAMES = ['trust_id',
'consumer_id',
'access_token_id',
'audit_id',
'audit_chain_id',
'expires_at',
'domain_id',
'project_id',
@ -86,6 +88,8 @@ class RevokeEvent(object):
'domain_id',
'domain_scope_id',
'project_id',
'audit_id',
'audit_chain_id',
]
event = dict((key, self.__dict__[key]) for key in keys
if self.__dict__[key] is not None)
@ -257,7 +261,10 @@ def build_token_values_v2(access, default_domain_id):
token_values = {
'expires_at': timeutils.normalize_time(token_expires_at),
'issued_at': timeutils.normalize_time(
timeutils.parse_isotime(token_data['issued_at']))}
timeutils.parse_isotime(token_data['issued_at'])),
'audit_id': token_data.get('audit_ids', [None])[0],
'audit_chain_id': token_data.get('audit_ids', [None])[-1],
}
token_values['user_id'] = access.get('user', {}).get('id')
@ -303,7 +310,10 @@ def build_token_values(token_data):
token_values = {
'expires_at': timeutils.normalize_time(token_expires_at),
'issued_at': timeutils.normalize_time(
timeutils.parse_isotime(token_data['issued_at']))}
timeutils.parse_isotime(token_data['issued_at'])),
'audit_id': token_data.get('audit_ids', [None])[0],
'audit_chain_id': token_data.get('audit_ids', [None])[-1],
}
user = token_data.get('user')
if user is not None:

View File

@ -30,6 +30,7 @@ from keystone import tests
from keystone.tests import default_fixtures
from keystone.tests.ksfixtures import database
from keystone import token
from keystone.token import provider
from keystone import trust
@ -489,6 +490,116 @@ class AuthWithToken(AuthTest):
self.assertThat(audit_ids, matchers.HasLength(2))
self.assertThat(audit_ids[-1], matchers.Equals(starting_audit_id))
def test_revoke_by_audit_chain_id_original_token(self):
self.config_fixture.config(group='token', revoke_by_id=False)
context = {}
# get a token
body_dict = _build_user_auth(username='FOO', password='foo2')
unscoped_token = self.controller.authenticate(context, body_dict)
token_id = unscoped_token['access']['token']['id']
# get a second token
body_dict = _build_user_auth(token=unscoped_token["access"]["token"])
unscoped_token_2 = self.controller.authenticate(context, body_dict)
token_2_id = unscoped_token_2['access']['token']['id']
self.token_provider_api.revoke_token(token_id, revoke_chain=True)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_id)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_2_id)
def test_revoke_by_audit_chain_id_chained_token(self):
self.config_fixture.config(group='token', revoke_by_id=False)
context = {}
# get a token
body_dict = _build_user_auth(username='FOO', password='foo2')
unscoped_token = self.controller.authenticate(context, body_dict)
token_id = unscoped_token['access']['token']['id']
# get a second token
body_dict = _build_user_auth(token=unscoped_token["access"]["token"])
unscoped_token_2 = self.controller.authenticate(context, body_dict)
token_2_id = unscoped_token_2['access']['token']['id']
self.token_provider_api.revoke_token(token_2_id, revoke_chain=True)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_id)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_2_id)
def _mock_audit_info(self, parent_audit_id):
# NOTE(morgainfainberg): The token model and other cases that are
# extracting the audit id expect 'None' if the audit id doesn't
# exist. This ensures that the audit_id is None and the
# audit_chain_id will also return None.
return [None, None]
def test_revoke_with_no_audit_info(self):
self.config_fixture.config(group='token', revoke_by_id=False)
context = {}
with mock.patch.object(provider, 'audit_info', self._mock_audit_info):
# get a token
body_dict = _build_user_auth(username='FOO', password='foo2')
unscoped_token = self.controller.authenticate(context, body_dict)
token_id = unscoped_token['access']['token']['id']
# get a second token
body_dict = _build_user_auth(
token=unscoped_token['access']['token'])
unscoped_token_2 = self.controller.authenticate(context, body_dict)
token_2_id = unscoped_token_2['access']['token']['id']
self.token_provider_api.revoke_token(token_id, revoke_chain=True)
revoke_events = self.revoke_api.get_events()
self.assertThat(revoke_events, matchers.HasLength(1))
revoke_event = revoke_events[0].to_dict()
self.assertIn('expires_at', revoke_event)
self.assertEqual(unscoped_token_2['access']['token']['expires'],
revoke_event['expires_at'])
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_id)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_2_id)
# get a new token, with no audit info
body_dict = _build_user_auth(username='FOO', password='foo2')
unscoped_token = self.controller.authenticate(context, body_dict)
token_id = unscoped_token['access']['token']['id']
# get a second token
body_dict = _build_user_auth(
token=unscoped_token['access']['token'])
unscoped_token_2 = self.controller.authenticate(context, body_dict)
token_2_id = unscoped_token_2['access']['token']['id']
# Revoke by audit_id, no audit_info means both parent and child
# token are revoked.
self.token_provider_api.revoke_token(token_id)
revoke_events = self.revoke_api.get_events()
self.assertThat(revoke_events, matchers.HasLength(2))
revoke_event = revoke_events[1].to_dict()
self.assertIn('expires_at', revoke_event)
self.assertEqual(unscoped_token_2['access']['token']['expires'],
revoke_event['expires_at'])
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_id)
self.assertRaises(exception.TokenNotFound,
self.token_provider_api.validate_v2_token,
token_id=token_2_id)
class AuthWithPasswordCredentials(AuthTest):
def test_auth_invalid_user(self):

View File

@ -23,6 +23,7 @@ from keystone.contrib.revoke import model
from keystone import exception
from keystone import tests
from keystone.tests import test_backend_sql
from keystone.token import provider
def _new_id():
@ -92,7 +93,7 @@ def _matches(event, token_values):
# rest of the logic.
attribute_names = ['project_id',
'expires_at', 'trust_id', 'consumer_id',
'access_token_id']
'access_token_id', 'audit_id', 'audit_chain_id']
for attribute_name in attribute_names:
if getattr(event, attribute_name) is not None:
if (getattr(event, attribute_name) !=
@ -267,6 +268,22 @@ class RevokeTreeTests(tests.TestCase):
return self.tree.add_event(
model.RevokeEvent(user_id=user_id))
def _revoke_by_audit_id(self, audit_id):
event = self.tree.add_event(
model.RevokeEvent(audit_id=audit_id))
self.events.append(event)
return event
def _revoke_by_audit_chain_id(self, audit_chain_id, project_id=None,
domain_id=None):
event = self.tree.add_event(
model.RevokeEvent(audit_chain_id=audit_chain_id,
project_id=project_id,
domain_id=domain_id)
)
self.events.append(event)
return event
def _revoke_by_expiration(self, user_id, expires_at, project_id=None,
domain_id=None):
event = self.tree.add_event(
@ -355,6 +372,45 @@ class RevokeTreeTests(tests.TestCase):
self.removeEvent(event)
self._assertTokenNotRevoked(token_data_1)
def test_revoke_by_audit_id(self):
audit_id = provider.audit_info(parent_audit_id=None)[0]
token_data_1 = _sample_blank_token()
# Audit ID and Audit Chain ID are populated with the same value
# if the token is an original token
token_data_1['audit_id'] = audit_id
token_data_1['audit_chain_id'] = audit_id
event = self._revoke_by_audit_id(audit_id)
self._assertTokenRevoked(token_data_1)
audit_id_2 = provider.audit_info(parent_audit_id=audit_id)[0]
token_data_2 = _sample_blank_token()
token_data_2['audit_id'] = audit_id_2
token_data_2['audit_chain_id'] = audit_id
self._assertTokenNotRevoked(token_data_2)
self.removeEvent(event)
self._assertTokenNotRevoked(token_data_1)
def test_revoke_by_audit_chain_id(self):
audit_id = provider.audit_info(parent_audit_id=None)[0]
token_data_1 = _sample_blank_token()
# Audit ID and Audit Chain ID are populated with the same value
# if the token is an original token
token_data_1['audit_id'] = audit_id
token_data_1['audit_chain_id'] = audit_id
event = self._revoke_by_audit_chain_id(audit_id)
self._assertTokenRevoked(token_data_1)
audit_id_2 = provider.audit_info(parent_audit_id=audit_id)[0]
token_data_2 = _sample_blank_token()
token_data_2['audit_id'] = audit_id_2
token_data_2['audit_chain_id'] = audit_id
self._assertTokenRevoked(token_data_2)
self.removeEvent(event)
self._assertTokenNotRevoked(token_data_1)
self._assertTokenNotRevoked(token_data_2)
def test_by_user_project(self):
# When a user has a project-scoped token and the project-scoped token
# is revoked then the token is revoked.
@ -515,18 +571,24 @@ class RevokeTreeTests(tests.TestCase):
self.assertEqual(turn + 1, len(self.tree.revoke_map
['trust_id=*']
['consumer_id=*']
['access_token_id=*']))
['access_token_id=*']
['audit_id=*']
['audit_chain_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=*']
['audit_id=*']
['audit_chain_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=*']
['audit_id=*']
['audit_chain_id=*']
['expires_at=*']
['domain_id=*']))
# 10 users added
@ -534,6 +596,8 @@ class RevokeTreeTests(tests.TestCase):
['trust_id=*']
['consumer_id=*']
['access_token_id=*']
['audit_id=*']
['audit_chain_id=*']
['expires_at=*']
['domain_id=*']
['project_id=*']))
@ -554,7 +618,9 @@ class RevokeTreeTests(tests.TestCase):
self.assertEqual(i + 2, len(self.tree.revoke_map
['trust_id=*']
['consumer_id=*']
['access_token_id=*']),
['access_token_id=*']
['audit_id=*']
['audit_chain_id=*']),
'adding %s to %s' % (args,
self.tree.revoke_map))

View File

@ -37,6 +37,7 @@ from migrate.versioning import api as versioning_api
from oslo.db import exception as db_exception
from oslo.db.sqlalchemy import migration
from oslo.db.sqlalchemy import session as db_session
import six
import sqlalchemy.exc
from keystone.assignment.backends import sql as assignment_sql
@ -45,6 +46,7 @@ from keystone.common.sql import migrate_repo
from keystone.common.sql import migration_helpers
from keystone import config
from keystone.contrib import federation
from keystone.contrib import revoke
from keystone import exception
from keystone import tests
from keystone.tests import default_fixtures
@ -119,10 +121,14 @@ INITIAL_EXTENSION_TABLE_STRUCTURE = {
'revocation_event': [
'id', 'domain_id', 'project_id', 'user_id', 'role_id',
'trust_id', 'consumer_id', 'access_token_id',
'issued_before', 'expires_at', 'revoked_at',
'issued_before', 'expires_at', 'revoked_at', 'audit_id',
'audit_chain_id',
],
}
EXTENSIONS = {'federation': federation,
'revoke': revoke}
class SqlMigrateBase(tests.SQLDriverOverrides, tests.TestCase):
def initialize_sql(self):
@ -1413,18 +1419,38 @@ class VersionTests(SqlMigrateBase):
def test_extension_initial(self):
"""When get the initial version of an extension, it's 0."""
abs_path = migration_helpers.find_migrate_repo(federation)
for name, extension in six.iteritems(EXTENSIONS):
abs_path = migration_helpers.find_migrate_repo(extension)
migration.db_version_control(sql.get_engine(), abs_path)
version = migration_helpers.get_db_version(extension='federation')
self.assertEqual(0, version)
version = migration_helpers.get_db_version(extension=name)
self.assertEqual(0, version,
'Migrate version for %s is not 0' % name)
def test_extension_migrated(self):
"""When get the version after migrating an extension, it's not 0."""
abs_path = migration_helpers.find_migrate_repo(federation)
for name, extension in six.iteritems(EXTENSIONS):
abs_path = migration_helpers.find_migrate_repo(extension)
migration.db_version_control(sql.get_engine(), abs_path)
migration.db_sync(sql.get_engine(), abs_path)
version = migration_helpers.get_db_version(extension='federation')
self.assertTrue(version > 0, "Version didn't change after migrated?")
version = migration_helpers.get_db_version(extension=name)
self.assertTrue(
version > 0,
"Version for %s didn't change after migrated?" % name)
def test_extension_downgraded(self):
"""When get the version after downgrading an extension, it is 0."""
for name, extension in six.iteritems(EXTENSIONS):
abs_path = migration_helpers.find_migrate_repo(extension)
migration.db_version_control(sql.get_engine(), abs_path)
migration.db_sync(sql.get_engine(), abs_path)
version = migration_helpers.get_db_version(extension=name)
self.assertTrue(
version > 0,
"Version for %s didn't change after migrated?" % name)
migration.db_sync(sql.get_engine(), abs_path, version=0)
version = migration_helpers.get_db_version(extension=name)
self.assertEqual(0, version,
'Migrate version for %s is not 0' % name)
def test_unexpected_extension(self):
"""The version for an extension that doesn't exist raises ImportError.

View File

@ -19,6 +19,7 @@ import uuid
from keystoneclient.common import cms
from oslo.utils import timeutils
import six
from testtools import matchers
from testtools import testcase
@ -1341,48 +1342,45 @@ class TestTokenRevokeApi(TestTokenRevokeById):
expected_response = {'events': [{'domain_id': domain_id}]}
self.assertEqual(expected_response, events_response)
def assertValidRevokedTokenResponse(self, events_response, user_id,
project_id=None):
def assertValidRevokedTokenResponse(self, events_response, **kwargs):
events = events_response['events']
self.assertEqual(1, len(events))
self.assertEqual(user_id, events[0]['user_id'])
if project_id:
self.assertEqual(project_id, events[0]['project_id'])
self.assertIsNotNone(events[0]['expires_at'])
for k, v in six.iteritems(kwargs):
self.assertEqual(v, events[0].get(k))
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_event_data = {'user_id': user_id}
if project_id:
expected_event_data['project_id'] = project_id
expected_response = {'events': [expected_event_data]}
expected_response = {'events': [kwargs]}
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=200)
response = self.get('/auth/tokens', headers=headers,
expected_status=200).json_body['token']
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'],
project_id=self.project['id'])
self.assertValidRevokedTokenResponse(events_response,
audit_id=response['audit_ids'][0])
def test_revoke_v2_token(self):
token = self.get_v2_token()
headers = {'X-Subject-Token': token}
self.head('/auth/tokens', headers=headers, expected_status=200)
response = self.get('/auth/tokens', headers=headers,
expected_status=200).json_body['token']
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'])
self.assertValidRevokedTokenResponse(
events_response,
audit_id=response['audit_ids'][0])
def test_revoke_by_id_false_410(self):
self.get('/auth/tokens/OS-PKI/revoked', expected_status=410)
@ -1414,19 +1412,30 @@ class TestTokenRevokeApi(TestTokenRevokeById):
self.assertDomainInList(events, self.domainA['id'])
def assertUserAndExpiryInList(self, events, user_id, expires_at):
def assertEventDataInList(self, events, **kwargs):
found = False
for e in events:
# Timestamps in the event list are accurate to second.
expires_at = timeutils.parse_isotime(expires_at)
expires_at = timeutils.isotime(expires_at)
if e['user_id'] == user_id and e['expires_at'] == expires_at:
for key, value in six.iteritems(kwargs):
try:
if e[key] != value:
break
except KeyError:
# Break the loop and present a nice error instead of
# KeyError
break
else:
# If the value of the event[key] matches the value of the kwarg
# for each item in kwargs, the event was fully matched and
# the assertTrue below should succeed.
found = True
self.assertTrue(found,
'event with correct user_id %s and expires_at value '
'not in list' % user_id)
'event with correct values not in list, expected to '
'find event with key-value pairs. Expected: '
'"%(expected)s" Events: "%(events)s"' %
{'expected': ','.join(
["'%s=%s'" % (k, v) for k, v in six.iteritems(
kwargs)]),
'events': events})
def test_list_delete_token_shows_in_event_list(self):
self.role_data_fixtures()
@ -1456,11 +1465,9 @@ class TestTokenRevokeApi(TestTokenRevokeById):
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'],
project_id=self.project['id'])
self.assertEventDataInList(
events,
audit_id=token2['audit_ids'][1])
self.head('/auth/tokens', headers=headers, expected_status=404)
self.head('/auth/tokens', headers=headers2, expected_status=200)
self.head('/auth/tokens', headers=headers3, expected_status=200)

View File

@ -411,31 +411,43 @@ class Manager(manager.Manager):
self._validate_v2_token.invalidate(self, token_id)
self._validate_v3_token.invalidate(self, token_id)
def revoke_token(self, token_id):
def revoke_token(self, token_id, revoke_chain=False):
if self.revoke_api:
user_id = None
expires_at = None
domain_id = None
revoke_by_expires = False
project_id = None
domain_id = None
token_ref = self.persistence.get_token(token_id)
version = self.driver.get_token_version(token_ref)
token_ref = token_model.KeystoneToken(
token_id=token_id,
token_data=self.validate_token(token_id))
if version == self.V3:
user_id = token_ref['user']['id']
expires_at = token_ref['expires']
user_id = token_ref.user_id
expires_at = token_ref.expires
audit_id = token_ref.audit_id
audit_chain_id = token_ref.audit_chain_id
if token_ref.project_scoped:
project_id = token_ref.project_id
if token_ref.domain_scoped:
domain_id = token_ref.domain_id
token_data = token_ref['token_data']['token']
project_id = token_data.get('project', {}).get('id')
domain_id = token_data.get('domain', {}).get('id')
elif version == self.V2:
user_id = token_ref['user_id']
expires_at = token_ref['expires']
project_id = (token_ref.get('tenant') or {}).get('id')
if audit_id is None and not revoke_chain:
LOG.debug('Received token with no audit_id.')
revoke_by_expires = True
if audit_chain_id is None and revoke_chain:
LOG.debug('Received token with no audit_chain_id.', token_id)
revoke_by_expires = True
if revoke_by_expires:
self.revoke_api.revoke_by_expiration(user_id, expires_at,
project_id=project_id,
domain_id=domain_id)
elif revoke_chain:
self.revoke_api.revoke_by_audit_chain_id(audit_chain_id,
project_id=project_id,
domain_id=domain_id)
else:
self.revoke_api.revoke_by_audit_id(audit_id)
if CONF.token.revoke_by_id:
self.persistence.delete_token(token_id=token_id)