# Copyright 2013 Metacloud
#
# 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.
"""Keystone Caching Layer Implementation."""
import secrets
from dogpile.cache import api as cache_api
from dogpile.cache import region
from dogpile.cache import util
from oslo_cache import core as cache
from oslo_log import log
from keystone.common.cache import _context_cache
import keystone.conf
CONF = keystone.conf.CONF
LOG = log.getLogger(__name__)
[docs]class RegionInvalidationManager(object):
REGION_KEY_PREFIX = '<<<region>>>:'
def __init__(self, invalidation_region, region_name):
self._invalidation_region = invalidation_region
self._region_key = self.REGION_KEY_PREFIX + region_name
def _generate_new_id(self):
return secrets.token_bytes(10)
@property
def region_id(self):
return self._invalidation_region.get_or_create(
self._region_key, self._generate_new_id, expiration_time=-1)
[docs] def invalidate_region(self):
new_region_id = self._generate_new_id()
self._invalidation_region.set(self._region_key, new_region_id)
return new_region_id
[docs]class DistributedInvalidationStrategy(region.RegionInvalidationStrategy):
def __init__(self, region_manager):
self._region_manager = region_manager
[docs]def key_mangler_factory(invalidation_manager, orig_key_mangler):
def key_mangler(key):
# NOTE(dstanek): Since *all* keys go through the key mangler we
# need to make sure the region keys don't get the region_id added.
# If it were there would be no way to get to it, making the cache
# effectively useless.
if not invalidation_manager.is_region_key(key):
key = '%s:%s' % (key, invalidation_manager.region_id)
if orig_key_mangler:
key = orig_key_mangler(key)
return key
return key_mangler
[docs]def create_region(name):
"""Create a dopile region.
Wraps oslo_cache.core.create_region. This is used to ensure that the
Region is properly patched and allows us to more easily specify a region
name.
:param str name: The region name
:returns: The new region.
:rtype: :class:`dogpile.cache.region.CacheRegion`
"""
region = cache.create_region()
region.name = name # oslo.cache doesn't allow this yet
return region
CACHE_REGION = create_region(name='shared default')
CACHE_INVALIDATION_REGION = create_region(name='invalidation region')
register_model_handler = _context_cache._register_model_handler
[docs]def configure_cache(region=None):
if region is None:
region = CACHE_REGION
# NOTE(morganfainberg): running cache.configure_cache_region()
# sets region.is_configured, this must be captured before
# cache.configure_cache_region is called.
configured = region.is_configured
cache.configure_cache_region(CONF, region)
# Only wrap the region if it was not configured. This should be pushed
# to oslo_cache lib somehow.
if not configured:
region.wrap(_context_cache._ResponseCacheProxy)
region_manager = RegionInvalidationManager(
CACHE_INVALIDATION_REGION, region.name)
region.key_mangler = key_mangler_factory(
region_manager, region.key_mangler)
region.region_invalidator = DistributedInvalidationStrategy(
region_manager)
def _sha1_mangle_key(key):
"""Wrapper for dogpile's sha1_mangle_key.
dogpile's sha1_mangle_key function expects an encoded string, so we
should take steps to properly handle multiple inputs before passing
the key through.
NOTE(dstanek): this was copied directly from olso_cache
"""
try:
key = key.encode('utf-8', errors='xmlcharrefreplace')
except (UnicodeError, AttributeError):
# NOTE(stevemar): if encoding fails just continue anyway.
pass
return util.sha1_mangle_key(key)
[docs]def configure_invalidation_region():
if CACHE_INVALIDATION_REGION.is_configured:
return
# NOTE(dstanek): Configuring this region manually so that we control the
# expiration and can ensure that the keys don't expire.
config_dict = cache._build_cache_config(CONF)
config_dict['expiration_time'] = None # we don't want an expiration
CACHE_INVALIDATION_REGION.configure_from_config(
config_dict, '%s.' % CONF.cache.config_prefix)
# NOTE(breton): Wrap the cache invalidation region to avoid excessive
# calls to memcached, which would result in poor performance.
CACHE_INVALIDATION_REGION.wrap(_context_cache._ResponseCacheProxy)
# NOTE(morganfainberg): if the backend requests the use of a
# key_mangler, we should respect that key_mangler function. If a
# key_mangler is not defined by the backend, use the sha1_mangle_key
# mangler provided by dogpile.cache. This ensures we always use a fixed
# size cache-key.
if CACHE_INVALIDATION_REGION.key_mangler is None:
CACHE_INVALIDATION_REGION.key_mangler = _sha1_mangle_key
[docs]def get_memoization_decorator(group, expiration_group=None, region=None):
if region is None:
region = CACHE_REGION
return cache.get_memoization_decorator(
CONF, region, group, expiration_group=expiration_group
)
[docs]def is_replay(region, key, expiration_time=None):
"""Return whether ``key`` has already been recorded in ``region``.
Check this before honoring a one-time-use value (nonce, passcode,
receipt, ...), then call :func:`mark_used` once it is consumed.
:param region: a configured dogpile CacheRegion.
:param key: the identifying string for the one-time-use value.
:param expiration_time: seconds to honor a recorded value for,
overriding the region's own expiration_time. Pass the value's
real validity window plus a margin, so protection isn't at the
mercy of an operator's general-purpose ``[cache] expiration_time``.
:returns: True if ``key`` was already recorded (a replay).
"""
if not CONF.cache.enabled or CONF.cache.backend == 'dogpile.cache.null':
LOG.warning(
'Caching is disabled or using the null backend; one-time-use '
'replay protection for the "%s" cache region is degraded.',
region.name,
)
# backend_expiration_time (if set) purges entries natively in
# memcached/redis, independent of the expiration_time passed to
# region.get() below. Clamp to it rather than claim a longer window
# than the backend can actually honor.
backend_ttl = CONF.cache.backend_expiration_time
if (
expiration_time is not None
and backend_ttl is not None
and backend_ttl < expiration_time
):
LOG.warning(
'[cache] backend_expiration_time (%(backend_ttl)ss) is '
'shorter than the %(wanted)ss window requested for '
'"%(region)s"; raise it to avoid an early replay gap.',
{
'backend_ttl': backend_ttl,
'wanted': expiration_time,
'region': region.name,
},
)
expiration_time = backend_ttl
value = region.get(key, expiration_time=expiration_time)
return value is not cache_api.NO_VALUE
[docs]def mark_used(region, key):
"""Record ``key`` in ``region`` for a later :func:`is_replay` check.
Expiration is enforced on the read side by :func:`is_replay`.
"""
region.set(key, True)
Except where otherwise noted, this document is licensed under Creative Commons Attribution 3.0 License. See all OpenStack Legal Documents.