diff options
Diffstat (limited to 'keystone-moon/keystone/contrib/revoke')
13 files changed, 987 insertions, 0 deletions
diff --git a/keystone-moon/keystone/contrib/revoke/__init__.py b/keystone-moon/keystone/contrib/revoke/__init__.py new file mode 100644 index 00000000..58ba68db --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/__init__.py @@ -0,0 +1,13 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystone.contrib.revoke.core import * # noqa diff --git a/keystone-moon/keystone/contrib/revoke/backends/__init__.py b/keystone-moon/keystone/contrib/revoke/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/backends/__init__.py diff --git a/keystone-moon/keystone/contrib/revoke/backends/kvs.py b/keystone-moon/keystone/contrib/revoke/backends/kvs.py new file mode 100644 index 00000000..cc41fbee --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/backends/kvs.py @@ -0,0 +1,73 @@ +# 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 oslo_config import cfg +from oslo_utils import timeutils + +from keystone.common import kvs +from keystone.contrib import revoke +from keystone import exception +from keystone.openstack.common import versionutils + + +CONF = cfg.CONF + +_EVENT_KEY = 'os-revoke-events' +_KVS_BACKEND = 'openstack.kvs.Memory' + + +class Revoke(revoke.Driver): + + @versionutils.deprecated( + versionutils.deprecated.JUNO, + in_favor_of='keystone.contrib.revoke.backends.sql', + remove_in=+1, + what='keystone.contrib.revoke.backends.kvs') + 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 _list_events(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._list_events() + 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 list_events(self, last_fetch=None): + return self._prune_expired_events_and_get(last_fetch=last_fetch) + + def revoke(self, event): + self._prune_expired_events_and_get(new_event=event) diff --git a/keystone-moon/keystone/contrib/revoke/backends/sql.py b/keystone-moon/keystone/contrib/revoke/backends/sql.py new file mode 100644 index 00000000..1b0cde1e --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/backends/sql.py @@ -0,0 +1,104 @@ +# 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 sql +from keystone.contrib import revoke +from keystone.contrib.revoke import model + + +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) + audit_id = sql.Column(sql.String(32)) + audit_chain_id = sql.Column(sql.String(32)) + + +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 = sql.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 list_events(self, last_fetch=None): + self._prune_expired_events() + session = sql.get_session() + query = session.query(RevocationEvent).order_by( + RevocationEvent.revoked_at) + + if last_fetch: + query = query.filter(RevocationEvent.revoked_at > last_fetch) + + 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 = sql.get_session() + with session.begin(): + session.add(record) diff --git a/keystone-moon/keystone/contrib/revoke/controllers.py b/keystone-moon/keystone/contrib/revoke/controllers.py new file mode 100644 index 00000000..40151bae --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/controllers.py @@ -0,0 +1,44 @@ +# 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 oslo_utils import timeutils + +from keystone.common import controller +from keystone.common import dependency +from keystone import exception +from keystone.i18n import _ + + +@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.list_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( + context, + path=context['path']), + 'previous': None} + } + return response diff --git a/keystone-moon/keystone/contrib/revoke/core.py b/keystone-moon/keystone/contrib/revoke/core.py new file mode 100644 index 00000000..c7335690 --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/core.py @@ -0,0 +1,250 @@ +# 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 + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import timeutils +import six + +from keystone.common import cache +from keystone.common import dependency +from keystone.common import extension +from keystone.common import manager +from keystone.contrib.revoke import model +from keystone import exception +from keystone.i18n import _ +from keystone import notifications +from keystone.openstack.common import versionutils + + +CONF = cfg.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) + +MEMOIZE = cache.get_memoization_decorator(section='revoke') + + +def revoked_before_cutoff_time(): + expire_delta = datetime.timedelta( + seconds=CONF.token.expiration + CONF.revoke.expiration_buffer) + oldest = timeutils.utcnow() - expire_delta + return oldest + + +@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.model = model + + 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.revoke( + model.RevokeEvent(role_id=payload['resource_info'])) + + def _project_callback(self, service, resource_type, operation, + payload): + self.revoke( + model.RevokeEvent(project_id=payload['resource_info'])) + + def _domain_callback(self, service, resource_type, operation, + payload): + self.revoke( + model.RevokeEvent(domain_id=payload['resource_info'])) + + def _trust_callback(self, service, resource_type, operation, + payload): + self.revoke( + model.RevokeEvent(trust_id=payload['resource_info'])) + + def _consumer_callback(self, service, resource_type, operation, + payload): + self.revoke( + model.RevokeEvent(consumer_id=payload['resource_info'])) + + def _access_token_callback(self, service, resource_type, operation, + payload): + self.revoke( + model.RevokeEvent(access_token_id=payload['resource_info'])) + + def _group_callback(self, service, resource_type, operation, payload): + user_ids = (u['id'] for u in self.identity_api.list_users_in_group( + payload['resource_info'])) + for uid in user_ids: + self.revoke(model.RevokeEvent(user_id=uid)) + + def _register_listeners(self): + callbacks = { + notifications.ACTIONS.deleted: [ + ['OS-TRUST:trust', self._trust_callback], + ['OS-OAUTH1:consumer', self._consumer_callback], + ['OS-OAUTH1:access_token', self._access_token_callback], + ['role', self._role_callback], + ['user', self._user_callback], + ['project', self._project_callback], + ], + notifications.ACTIONS.disabled: [ + ['user', self._user_callback], + ['project', self._project_callback], + ['domain', self._domain_callback], + ], + notifications.ACTIONS.internal: [ + [notifications.INVALIDATE_USER_TOKEN_PERSISTENCE, + self._user_callback], + ] + } + + for event, cb_info in six.iteritems(callbacks): + for resource_type, callback_fns in cb_info: + notifications.register_event_callback(event, resource_type, + callback_fns) + + def revoke_by_user(self, user_id): + return self.revoke(model.RevokeEvent(user_id=user_id)) + + def _assert_not_domain_and_project_scoped(self, domain_id=None, + project_id=None): + if domain_id is not None and project_id is not None: + msg = _('The revoke call must not have both domain_id and ' + 'project_id. This is a bug in the Keystone server. The ' + 'current request is aborted.') + raise exception.UnexpectedError(exception=msg) + + @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): + + self._assert_not_domain_and_project_scoped(domain_id=domain_id, + project_id=project_id) + + self.revoke( + model.RevokeEvent(user_id=user_id, + expires_at=expires_at, + 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._assert_not_domain_and_project_scoped(domain_id=domain_id, + project_id=project_id) + + 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( + 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.revoke( + model.RevokeEvent(project_id=project_id, user_id=user_id)) + + def revoke_by_project_role_assignment(self, project_id, role_id): + self.revoke(model.RevokeEvent(project_id=project_id, role_id=role_id)) + + def revoke_by_domain_role_assignment(self, domain_id, role_id): + self.revoke(model.RevokeEvent(domain_id=domain_id, role_id=role_id)) + + @MEMOIZE + def _get_revoke_tree(self): + events = self.driver.list_events() + revoke_tree = model.RevokeTree(revoke_events=events) + + return revoke_tree + + 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 + + """ + if self._get_revoke_tree().is_revoked(token_values): + raise exception.TokenNotFound(_('Failed to validate token')) + + def revoke(self, event): + self.driver.revoke(event) + self._get_revoke_tree.invalidate(self) + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + """Interface for recording and reporting revocation events.""" + + @abc.abstractmethod + def list_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() # pragma: no cover + + @abc.abstractmethod + def revoke(self, event): + """register a revocation event + + :param event: An instance of + keystone.contrib.revoke.model.RevocationEvent + + """ + raise exception.NotImplemented() # pragma: no cover diff --git a/keystone-moon/keystone/contrib/revoke/migrate_repo/__init__.py b/keystone-moon/keystone/contrib/revoke/migrate_repo/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/migrate_repo/__init__.py diff --git a/keystone-moon/keystone/contrib/revoke/migrate_repo/migrate.cfg b/keystone-moon/keystone/contrib/revoke/migrate_repo/migrate.cfg new file mode 100644 index 00000000..0e61bcaa --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/migrate_repo/migrate.cfg @@ -0,0 +1,25 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=revoke + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] + +# When creating new change scripts, Migrate will stamp the new script with +# a version number. By default this is latest_version + 1. You can set this +# to 'true' to tell Migrate to use the UTC timestamp instead. +use_timestamp_numbering=False diff --git a/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py new file mode 100644 index 00000000..7927ce0c --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/001_revoke_table.py @@ -0,0 +1,47 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sqlalchemy as sql + + +def upgrade(migrate_engine): + # Upgrade operations go here. Don't create your own engine; bind + # migrate_engine to your metadata + meta = sql.MetaData() + meta.bind = migrate_engine + + service_table = sql.Table( + 'revocation_event', + meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('domain_id', sql.String(64)), + sql.Column('project_id', sql.String(64)), + sql.Column('user_id', sql.String(64)), + sql.Column('role_id', sql.String(64)), + sql.Column('trust_id', sql.String(64)), + sql.Column('consumer_id', sql.String(64)), + sql.Column('access_token_id', sql.String(64)), + sql.Column('issued_before', sql.DateTime(), nullable=False), + sql.Column('expires_at', sql.DateTime()), + sql.Column('revoked_at', sql.DateTime(), index=True, nullable=False)) + service_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + # Operations to reverse the above upgrade go here. + meta = sql.MetaData() + meta.bind = migrate_engine + + tables = ['revocation_event'] + for t in tables: + table = sql.Table(t, meta, autoload=True) + table.drop(migrate_engine, checkfirst=True) diff --git a/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/002_add_audit_id_and_chain_to_revoke_table.py b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/002_add_audit_id_and_chain_to_revoke_table.py new file mode 100644 index 00000000..bee6fb2a --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/002_add_audit_id_and_chain_to_revoke_table.py @@ -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') diff --git a/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/__init__.py b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/migrate_repo/versions/__init__.py diff --git a/keystone-moon/keystone/contrib/revoke/model.py b/keystone-moon/keystone/contrib/revoke/model.py new file mode 100644 index 00000000..5e92042d --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/model.py @@ -0,0 +1,365 @@ +# 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 oslo_utils 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', + 'audit_id', + 'audit_chain_id', + 'expires_at', + 'domain_id', + 'project_id', + 'user_id', + 'role_id'] + + +# Additional arguments for creating a RevokeEvent +_EVENT_ARGS = ['issued_before', 'revoked_at'] + +# Names of attributes in the RevocationEvent, including "virtual" attributes. +# Virtual attributes are those added based on other values. +_EVENT_NAMES = _NAMES + ['domain_scope_id'] + +# 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.domain_id and self.expires_at: + # This is revoking a domain-scoped token. + self.domain_scope_id = self.domain_id + self.domain_id = None + else: + # This is revoking all tokens for a domain. + self.domain_scope_id = None + + if self.expires_at is not None: + # Trim off the expiration time because MySQL timestamps are only + # accurate to the second. + self.expires_at = self.expires_at.replace(microsecond=0) + + 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', + 'domain_scope_id', + 'project_id', + 'audit_id', + 'audit_chain_id', + ] + event = {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) + 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, _EVENT_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 _EVENT_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' + + """ + # Alternative names to be checked in token for every field in + # revoke tree. + alternatives = { + 'user_id': ['user_id', 'trustor_id', 'trustee_id'], + 'domain_id': ['identity_domain_id', 'assignment_domain_id'], + # For a domain-scoped token, the domain is in assignment_domain_id. + 'domain_scope_id': ['assignment_domain_id', ], + } + # Contains current forest (collection of trees) to be checked. + partial_matches = [self.revoke_map] + # We iterate over every layer of our revoke tree (except the last one). + for name in _EVENT_NAMES: + # bundle is the set of partial matches for the next level down + # the tree + bundle = [] + wildcard = '%s=*' % (name,) + # For every tree in current forest. + for tree in partial_matches: + # If there is wildcard node on current level we take it. + bundle.append(tree.get(wildcard)) + if name == 'role_id': + # Roles are very special since a token has a list of them. + # If the revocation event matches any one of them, + # revoke the token. + for role_id in token_data.get('roles', []): + bundle.append(tree.get('role_id=%s' % role_id)) + else: + # For other fields we try to get any branch that concur + # with any alternative field in the token. + for alt_name in alternatives.get(name, [name]): + bundle.append( + tree.get('%s=%s' % (name, token_data[alt_name]))) + # tree.get returns `None` if there is no match, so `bundle.append` + # adds a 'None' entry. This call remoes the `None` entries. + partial_matches = [x for x in bundle if x is not None] + if not partial_matches: + # If we end up with no branches to follow means that the token + # is definitely not in the revoke tree and all further + # iterations will be for nothing. + return False + + # The last (leaf) level is checked in a special way because we verify + # issued_at field differently. + for leaf in partial_matches: + try: + if leaf['issued_before'] > token_data['issued_at']: + return True + except KeyError: + pass + # If we made it out of the loop then no element in revocation tree + # corresponds to our token and it is good. + return False + + +def build_token_values_v2(access, default_domain_id): + token_data = access['token'] + + token_expires_at = timeutils.parse_isotime(token_data['expires']) + + # Trim off the microseconds because the revocation event only has + # expirations accurate to the second. + token_expires_at = token_expires_at.replace(microsecond=0) + + token_values = { + 'expires_at': timeutils.normalize_time(token_expires_at), + 'issued_at': timeutils.normalize_time( + 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') + + 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_expires_at = timeutils.parse_isotime(token_data['expires_at']) + + # Trim off the microseconds because the revocation event only has + # expirations accurate to the second. + token_expires_at = token_expires_at.replace(microsecond=0) + + token_values = { + 'expires_at': timeutils.normalize_time(token_expires_at), + 'issued_at': timeutils.normalize_time( + 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: + token_values['user_id'] = user['id'] + # Federated users do not have a domain, be defensive and get the user + # domain set to None in the federated user case. + token_values['identity_domain_id'] = user.get('domain', {}).get('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 + + domain = token_data.get('domain') + if domain is not None: + token_values['assignment_domain_id'] = domain['id'] + else: + token_values['assignment_domain_id'] = None + + role_list = [] + roles = token_data.get('roles') + if roles is not None: + for role in roles: + role_list.append(role['id']) + token_values['roles'] = role_list + + trust = token_data.get('OS-TRUST:trust') + if trust is None: + token_values['trust_id'] = None + token_values['trustor_id'] = None + token_values['trustee_id'] = None + else: + token_values['trust_id'] = trust['id'] + token_values['trustor_id'] = trust['trustor_user']['id'] + token_values['trustee_id'] = trust['trustee_user']['id'] + + oauth1 = token_data.get('OS-OAUTH1') + if oauth1 is None: + token_values['consumer_id'] = None + token_values['access_token_id'] = None + else: + token_values['consumer_id'] = oauth1['consumer_id'] + token_values['access_token_id'] = oauth1['access_token_id'] + return token_values diff --git a/keystone-moon/keystone/contrib/revoke/routers.py b/keystone-moon/keystone/contrib/revoke/routers.py new file mode 100644 index 00000000..4d2edfc0 --- /dev/null +++ b/keystone-moon/keystone/contrib/revoke/routers.py @@ -0,0 +1,29 @@ +# 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 json_home +from keystone.common import wsgi +from keystone.contrib.revoke import controllers + + +class RevokeExtension(wsgi.V3ExtensionRouter): + + PATH_PREFIX = '/OS-REVOKE' + + def add_routes(self, mapper): + revoke_controller = controllers.RevokeController() + self._add_resource( + mapper, revoke_controller, + path=self.PATH_PREFIX + '/events', + get_action='list_revoke_events', + rel=json_home.build_v3_extension_resource_relation( + 'OS-REVOKE', '1.0', 'events')) |