Improve configuration of out-of-tree identity drivers

Official recommendation for operators is to develop out-of-tree drivers
for the missing functionality. While this is easily possible there are
hurdles for configuring such drivers. Configuring them using FS requires
restart of Keystone when i.e. adding new domains to be processed with
the custom driver. Using database is a much better and dynamical
approach, but it currently is not allowing drivers to have specific
configuration. This change improves this flaws and contains of 3
individual parts that are submitted together to make testing easier.

- Allowing driver to register supported configuration option before
  loading the driver (invoke DriverManager without calling the driver
  and search for specific method present).
- Allow changing driver specific configuration through API (add 2 more
  configuration options enabling listed options in the API)
- Documentation changes.

Change-Id: I99fa798ef60cdb7a488fe55de76cd931c6db3e89
This commit is contained in:
Artem Goncharov 2024-05-31 15:32:27 +02:00
parent af53770e62
commit 41ab96ba8e
8 changed files with 307 additions and 5 deletions

View File

@ -74,6 +74,62 @@ The TLDR; steps (and too long didn't write yet):
5. Configure your new driver in ``keystone.conf``
6. Sit back and enjoy!
Identity Driver Configuration
-----------------------------
As described in the :ref:`domain_specific_configuration` there are 2 ways of
configuring domain specific drivers: using files and using database.
Configuration with files is straight forward but is having a major disadvantage
of requiring restart of Keystone for the refresh of configuration or even for
Keystone to start using chosen driver after adding a new domain.
Configuring drivers using database is a flexible alternative that allows
dynamic reconfiguration and even changes using the API (requires admin
privileges by default). There are 2 independent parts for this to work properly:
Defining configuration options
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Driver class (as pointed by EntryPoints) may have a static method
`register_opts` accepting `conf` argument. This method, if present, is being
invoked during loading the driver and registered options are then
available when the driver is being instantiated.
.. code-block:: python
class CustomDriver(base.IdentityDriverBase):
@classmethod
def register_opts(cls, conf):
grp = cfg.OptGroup("foo")
opts = [cfg.StrOpt("opt1")]
conf.register_group(grp)
conf.register_opts(opts, group=grp)
def __init__(self, conf=None):
# conf contains options registered above and domain specific values
# being set.
pass
...
Allowing domain configuration per API
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A safety measure of the Keystone domain configuration API is that options
allowed for the change need to be explicitly whitelisted. This is done
in the `domain_config` section of the main Keystone configuration file.
.. code-block:: cfg
[domain_config]
additional_whitelisted_options=<GROUP_NAME>:[opt1,opt2,opt3]
additional_sensitive_options=<GROUP_NAME>:[password]
The `<GROUP_NAME>` is the name of the configuration group as defined by the
driver. Sensitive options are not included in the GET api call and are stored
in a separate database table.
Driver Interface Changes
------------------------

View File

@ -42,12 +42,32 @@ Time-to-live (TTL, in seconds) to cache domain-specific configuration data.
This has no effect unless `[domain_config] caching` is enabled.
"""))
additional_whitelisted_options = cfg.Opt(
'additional_whitelisted_options',
type=cfg.types.Dict(value_type=cfg.types.List(bounds=True)),
help=utils.fmt("""
Additional whitelisted domain-specific options for out-of-tree drivers.
This is a dictonary of lists with the key being the group name and value a list
of group options.""")
)
additional_sensitive_options = cfg.Opt(
'additional_sensitive_options',
type=cfg.types.Dict(value_type=cfg.types.List(bounds=True)),
help=utils.fmt("""
Additional sensitive domain-specific options for out-of-tree drivers.
This is a dictonary of lists with the key being the group name and value a list
of group options.""")
)
GROUP_NAME = __name__.split('.')[-1]
ALL_OPTS = [
driver,
caching,
cache_time,
additional_whitelisted_options,
additional_sensitive_options
]

View File

@ -134,6 +134,16 @@ class IdentityDriverBase(object, metaclass=abc.ABCMeta):
"""
# @classmethod
# def register_opts(cls, conf):
# """Register driver specific configuration options.
# For domain configuration being stored in the database it is necessary
# for the driver to register configuration options. This method is
# optional and if it is not present no options are registered.
# """
# pass
def _get_conf(self):
try:
return self.conf or CONF

View File

@ -25,6 +25,7 @@ import uuid
from oslo_config import cfg
from oslo_log import log
from pycadf import reason
import stevedore
from keystone import assignment # TODO(lbragstad): Decouple this dependency
from keystone.common import cache
@ -64,6 +65,23 @@ REGISTRATION_ATTEMPTS = 10
SQL_DRIVER = 'SQL'
def get_driver(namespace, driver_name, *args):
"""Get identity driver without initializing.
The method is invoked to be able to introspect domain specific driver
looking for additional configuration options required by the driver.
"""
try:
driver_manager = stevedore.DriverManager(namespace,
driver_name,
invoke_on_load=False,
invoke_args=args)
return driver_manager.driver
except stevedore.exception.NoMatches:
msg = (_('Unable to find %(name)r driver in %(namespace)r.'))
raise ImportError(msg % {'name': driver_name, 'namespace': namespace})
class DomainConfigs(provider_api.ProviderAPIMixin, dict):
"""Discover, store and provide access to domain specific configs.
@ -262,12 +280,51 @@ class DomainConfigs(provider_api.ProviderAPIMixin, dict):
default_config_files=[],
default_config_dirs=[])
# Try to identify the required driver for the domain to let it register
# supported configuration options. In difference to the FS based
# configuration this is being set through `oslo_cfg.set_override` and
# thus require special treatment.
try:
driver_name = specific_config.get("identity", {}).get(
"driver", domain_config["cfg"].identity.driver)
# For the non in-tree drivers ...
if driver_name not in ["sql", "ldap"]:
# Locate the driver without invoking ...
driver = get_driver(
Manager.driver_namespace, driver_name)
# Check whether it wants to register additional config options
# ...
if hasattr(driver, "register_opts"):
# And register them for the domain_config (not the global
# Keystone config)
driver.register_opts(domain_config["cfg"])
except Exception as ex:
# If we failed for some reason - something wrong with the driver,
# so let's just skip registering config options. This matches older
# behavior of Keystone where out-of-tree drivers were not able to
# register config options with the DB configuration loading branch.
LOG.debug(
f"Exception during attempt to load domain specific "
f"configuration options: {ex}")
# Override any options that have been passed in as specified in the
# database.
for group in specific_config:
for option in specific_config[group]:
domain_config['cfg'].set_override(
option, specific_config[group][option], group)
# NOTE(gtema): Very first time default driver is being ordered
# to process the domain. This will change once initialization
# completes. Until the driver specific configuration is being
# registered `set_override` will fail for options not known by
# the core Keystone. Make this loading not failing letting code
# to complete the process properly.
try:
domain_config['cfg'].set_override(
option, specific_config[group][option], group)
except (cfg.NoSuchOptError, cfg.NoSuchGroupError):
# Error to register config overrides for wrong driver. This
# is not worth of logging since it is a normal case during
# Keystone initialization.
pass
domain_config['cfg_overrides'] = specific_config
domain_config['driver'] = self._load_driver(domain_config)

View File

@ -1124,6 +1124,13 @@ class DomainConfigManager(manager.Manager):
'option': option}
raise exception.UnexpectedError(exception=msg)
if CONF.domain_config.additional_whitelisted_options:
self.whitelisted_options.update(
**CONF.domain_config.additional_whitelisted_options)
if CONF.domain_config.additional_sensitive_options:
self.sensitive_options.update(
**CONF.domain_config.additional_sensitive_options)
if (group and group not in self.whitelisted_options and
group not in self.sensitive_options):
msg = _('Group %(group)s is not supported '
@ -1131,15 +1138,15 @@ class DomainConfigManager(manager.Manager):
raise exception.InvalidDomainConfig(reason=msg)
if option:
if (option not in self.whitelisted_options[group] and option not in
self.sensitive_options[group]):
if (option not in self.whitelisted_options.get(group, {})
and option not in self.sensitive_options.get(group, {})):
msg = _('Option %(option)s in group %(group)s is not '
'supported for domain specific configurations') % {
'group': group, 'option': option}
raise exception.InvalidDomainConfig(reason=msg)
def _is_sensitive(self, group, option):
return option in self.sensitive_options[group]
return option in self.sensitive_options.get(group, {})
def _config_to_list(self, config):
"""Build list of options for use by backend drivers."""

View File

@ -0,0 +1,94 @@
# 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.
"""Fake driver to test out-of-tree drivers handling."""
from oslo_config import cfg
from keystone import exception
from keystone.identity.backends import base
class FooDriver(base.IdentityDriverBase):
"""Fake out-of-tree driver.
It does not make much sense to inherit from BaseClass, but in certain
places across the code methods are invoked
"""
@classmethod
def register_opts(cls, conf):
grp = cfg.OptGroup("foo")
opts = [cfg.StrOpt("opt1")]
conf.register_group(grp)
conf.register_opts(opts, group=grp)
def authenticate(self, user_id, password):
raise exception.NotImplemented() # pragma: no cover
def create_user(self, user_id, user):
raise exception.NotImplemented() # pragma: no cover
def list_users(self, hints):
raise exception.NotImplemented() # pragma: no cover
def unset_default_project_id(self, project_id):
raise exception.NotImplemented() # pragma: no cover
def list_users_in_group(self, group_id, hints):
raise exception.NotImplemented() # pragma: no cover
def get_user(self, user_id):
raise exception.NotImplemented() # pragma: no cover
def update_user(self, user_id, user):
raise exception.NotImplemented() # pragma: no cover
def change_password(self, user_id, new_password):
raise exception.NotImplemented() # pragma: no cover
def add_user_to_group(self, user_id, group_id):
raise exception.NotImplemented() # pragma: no cover
def check_user_in_group(self, user_id, group_id):
raise exception.NotImplemented() # pragma: no cover
def remove_user_from_group(self, user_id, group_id):
raise exception.NotImplemented() # pragma: no cover
def delete_user(self, user_id):
raise exception.NotImplemented() # pragma: no cover
def get_user_by_name(self, user_name, domain_id):
raise exception.NotImplemented() # pragma: no cover
def create_group(self, group_id, group):
raise exception.NotImplemented() # pragma: no cover
def list_groups(self, hints):
raise exception.NotImplemented() # pragma: no cover
def list_groups_for_user(self, user_id, hints):
raise exception.NotImplemented() # pragma: no cover
def get_group(self, group_id):
raise exception.NotImplemented() # pragma: no cover
def get_group_by_name(self, group_name, domain_id):
raise exception.NotImplemented() # pragma: no cover
def update_group(self, group_id, group):
raise exception.NotImplemented() # pragma: no cover
def delete_group(self, group_id):
raise exception.NotImplemented() # pragma: no cover

View File

@ -12,12 +12,14 @@
"""Unit tests for core identity behavior."""
import fixtures
import itertools
import os
from unittest import mock
import uuid
from oslo_config import fixture as config_fixture
import stevedore
from keystone.common import provider_api
import keystone.conf
@ -25,6 +27,7 @@ from keystone import exception
from keystone import identity
from keystone.tests import unit
from keystone.tests.unit import default_fixtures
from keystone.tests.unit.identity.backends import fake_driver
from keystone.tests.unit.ksfixtures import database
@ -181,3 +184,50 @@ class TestDatabaseDomainConfigs(unit.TestCase):
self.assertEqual(CONF.ldap.suffix, res.ldap.suffix)
self.assertEqual(CONF.ldap.use_tls, res.ldap.use_tls)
self.assertEqual(CONF.ldap.query_scope, res.ldap.query_scope)
def test_loading_config_from_database_out_of_tree(self):
# Test domain config loading for out-of-tree driver supporting own
# config options
# Prepare fake driver
extension = stevedore.extension.Extension(
name="foo", entry_point=None,
obj=fake_driver.FooDriver(), plugin=None
)
fake_driver_manager = stevedore.DriverManager.make_test_instance(
extension, namespace="keystone.identity"
)
# replace DriverManager with a patched test instance
self.useFixture(
fixtures.MockPatchObject(
stevedore, "DriverManager", return_value=fake_driver_manager
)
).mock
self.config_fixture.config(
domain_configurations_from_database=True, group="identity"
)
self.config_fixture.config(
additional_whitelisted_options={"foo": ["opt1"]},
group="domain_config"
)
domain = unit.new_domain_ref()
PROVIDERS.resource_api.create_domain(domain["id"], domain)
# Override two config options for our domain
conf = {
"foo": {"opt1": uuid.uuid4().hex},
"identity": {"driver": "foo"}}
PROVIDERS.domain_config_api.create_config(domain["id"], conf)
domain_config = identity.DomainConfigs()
domain_config.setup_domain_drivers("foo", PROVIDERS.resource_api)
# Make sure our two overrides are in place, and others are not affected
res = domain_config.get_domain_conf(domain["id"])
self.assertEqual(conf["foo"]["opt1"], res.foo.opt1)
# Reset whitelisted options in the provider directly. Due to the fact
# that there are too many singletons used around the code basis there
# is a chance of clash when other API domain_config tests are being
# executed by the same process. It is NOT ENOUGH just to invoke reset
# on fixtures.
PROVIDERS.domain_config_api.whitelisted_options.pop("foo", None)

View File

@ -0,0 +1,8 @@
---
features:
- |
Improve configuration management for the out-of-tree identity drivers. When
driver implements a special method it is being invoked before instantiating
the driver when reading configuration from the database. Also 2 new
`domain_config` section configuration options are added to allow such
driver specific parameters to be managed using the API.