Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion crates/config/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

use crate::common::default_sql_driver;
use crate::common::{default_sql_driver, default_true};
use crate::pagination::ListLimitConfig;

/// Identity provider.
Expand All @@ -40,6 +40,19 @@ pub struct IdentityProvider {
#[serde(default = "default_domain_config_dir")]
pub domain_config_dir: PathBuf,

/// Whether per-domain identity/LDAP driver configuration is loaded from
/// `domain_config_dir` files. Off by default, as in python-keystone; the
/// resolution layer overlays file-based configuration only when this is
/// set.
#[serde(default)]
pub domain_specific_drivers_enabled: bool,

/// Whether per-domain configuration is taken from the database, at the
/// highest precedence, in addition to any file-based configuration. On by
/// default, matching python-keystone.
#[serde(default = "default_true")]
pub domain_configurations_from_database: bool,

/// Identity provider driver.
#[serde(default = "default_sql_driver")]
pub driver: String,
Expand Down Expand Up @@ -71,6 +84,8 @@ impl Default for IdentityProvider {
caching: false,
default_domain_id: default_domain_id(),
domain_config_dir: default_domain_config_dir(),
domain_specific_drivers_enabled: false,
domain_configurations_from_database: default_true(),
driver: default_sql_driver(),
max_password_length: default_max_password_length(),
password_hashing_algorithm: PasswordHashingAlgo::Bcrypt,
Expand Down
39 changes: 39 additions & 0 deletions crates/core-types/src/domain_config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,45 @@ impl DomainConfig {
.collect()
}

/// Overlay another configuration onto this one, option by option.
///
/// Every option `over` sets replaces this configuration's; an option only
/// this configuration sets is kept; a group present on only one side is
/// taken whole. This is how the resolution layer stacks the file-based
/// configuration under the database one, which takes precedence (issue
/// #958).
///
/// Both sides are expected to have come from [`Self::from_value`] or
/// [`Self::from_options`], so their options are already whitelist-filtered;
/// like [`Self::resolve`], this does not re-validate.
///
/// # Parameters
/// - `over`: The configuration whose options win.
pub fn overlay(&mut self, over: &DomainConfig) {
for group in over.groups.values() {
let target = self
.groups
.entry(group.name)
.or_insert_with(|| DomainConfigGroup::new(group.name));
for (option, value) in &group.options {
target.options.insert(option.clone(), value.clone());
}
}
}

/// [`Self::overlay`] as a chainable, consuming call.
///
/// # Parameters
/// - `over`: The configuration whose options win.
///
/// # Returns
/// - `Self` - This configuration with `over` applied.
#[must_use]
pub fn overlaid_with(mut self, over: &DomainConfig) -> Self {
self.overlay(over);
self
}

/// The domain's `[identity]` section: the global one with the domain's
/// overrides applied.
///
Expand Down
77 changes: 77 additions & 0 deletions crates/core-types/src/domain_config/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,3 +909,80 @@ mod substitution {
);
}
}

mod overlay {
//! Option-by-option merge of one configuration onto another — the primitive
//! the resolution layer stacks the file configuration under the database
//! one with.

use super::*;

/// A group's option, or `None` when the group or option is unset.
fn option<'a>(
config: &'a DomainConfig,
group: DomainConfigGroupName,
name: &str,
) -> Option<&'a Value> {
config.group(group).and_then(|group| group.get(name))
}

#[test]
fn the_overlay_wins_an_option_both_sides_set() {
let mut base = config_from(json!({"ldap": {"url": "ldap://file"}}));
base.overlay(&config_from(json!({"ldap": {"url": "ldap://db"}})));
assert_eq!(
option(&base, DomainConfigGroupName::Ldap, "url"),
Some(&json!("ldap://db"))
);
}

#[test]
fn an_option_only_the_base_set_is_kept() {
let base = config_from(json!({"ldap": {"url": "ldap://file", "suffix": "dc=file"}}))
.overlaid_with(&config_from(json!({"ldap": {"url": "ldap://db"}})));
assert_eq!(
option(&base, DomainConfigGroupName::Ldap, "url"),
Some(&json!("ldap://db")),
"the overlapping option took the overlay value"
);
assert_eq!(
option(&base, DomainConfigGroupName::Ldap, "suffix"),
Some(&json!("dc=file")),
"the base-only option survived"
);
}

#[test]
fn a_group_present_on_one_side_only_is_taken_whole() {
let merged = config_from(json!({"identity": {"driver": "ldap"}}))
.overlaid_with(&config_from(json!({"ldap": {"url": "ldap://db"}})));
assert_eq!(
option(&merged, DomainConfigGroupName::Identity, "driver"),
Some(&json!("ldap"))
);
assert_eq!(
option(&merged, DomainConfigGroupName::Ldap, "url"),
Some(&json!("ldap://db"))
);
}

#[test]
fn overlays_a_sensitive_option() {
let base = config_from(json!({"ldap": {"password": "file-secret"}}))
.overlaid_with(&config_from(json!({"ldap": {"password": PASSWORD}})));
assert_eq!(
option(&base, DomainConfigGroupName::Ldap, "password"),
Some(&json!(PASSWORD))
);
}

#[test]
fn overlaying_an_empty_configuration_changes_nothing() {
let base = config_from(json!({"ldap": {"url": "ldap://file"}}))
.overlaid_with(&DomainConfig::new());
assert_eq!(
option(&base, DomainConfigGroupName::Ldap, "url"),
Some(&json!("ldap://file"))
);
}
}
2 changes: 2 additions & 0 deletions crates/core/src/domain_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

pub mod backend;
pub mod error;
pub mod resolver;

pub use backend::DomainConfigBackend;
pub use error::DomainConfigProviderError;
pub use resolver::DomainConfigResolver;
140 changes: 140 additions & 0 deletions crates/core/src/domain_config/resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// 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.
//
// SPDX-License-Identifier: Apache-2.0

//! # Domain configuration resolution
//!
//! A domain's configuration can come from two places: per-domain files under
//! `[identity] domain_config_dir` (the `fs` driver) and the database (the `sql`
//! driver, written through the config API). python-keystone layers the file
//! configuration first and lets the database one override it; this module does
//! the same, gated by the two `[identity]` switches:
//!
//! - `domain_specific_drivers_enabled` — consult the file source at all;
//! - `domain_configurations_from_database` — consult the database source, which
//! wins where both set the same option.
//!
//! The result is the raw, still-serializable [`DomainConfig`] the config API
//! returns. A consumer that needs a configuration a driver can use — the
//! identity backend selection of issue #960 — runs
//! [`DomainConfig::substitute`] and then `resolve_identity` / `resolve_ldap`
//! on top of it; those steps inline secrets and so produce a value that must
//! not reach a response, which is why they are deliberately left to the
//! caller.

use std::sync::Arc;

use openstack_keystone_config::Config;
use openstack_keystone_core_types::domain_config::DomainConfig;

use crate::domain_config::backend::DomainConfigBackend;
use crate::domain_config::error::DomainConfigProviderError;
use crate::keystone::ServiceState;
use crate::plugin_manager::PluginManagerApi;

/// Merges a domain's file-based and database-stored configuration into one.
///
/// Holds a handle to each source it is configured to use; a source that is
/// switched off in `[identity]` is simply `None`, and a resolver with neither
/// resolves every domain to the empty configuration.
pub struct DomainConfigResolver {
/// The `fs` driver, `Some` when
/// `[identity] domain_specific_drivers_enabled` is set.
file: Option<Arc<dyn DomainConfigBackend>>,
/// The `sql` driver, `Some` when
/// `[identity] domain_configurations_from_database` is set. Overrides the
/// file source option by option.
database: Option<Arc<dyn DomainConfigBackend>>,
}

impl DomainConfigResolver {
/// Wire the resolver from the running configuration and the registered
/// domain-config backends.
///
/// # Parameters
/// - `config`: The running service configuration; its `[identity]`
/// switches decide which sources are consulted.
/// - `plugin_manager`: Provides the `"fs"` / `"sql"` backends by name.
///
/// # Returns
/// - `Result<Self, DomainConfigProviderError>` - The resolver, or
/// [`DomainConfigProviderError::UnsupportedDriver`] when an enabled
/// source has no registered backend.
pub fn new<P: PluginManagerApi>(
config: &Config,
plugin_manager: &P,
) -> Result<Self, DomainConfigProviderError> {
let file = if config.identity.domain_specific_drivers_enabled {
Some(plugin_manager.get_domain_config_backend("fs")?.clone())
} else {
None
};
let database = if config.identity.domain_configurations_from_database {
Some(plugin_manager.get_domain_config_backend("sql")?.clone())
} else {
None
};
Ok(Self { file, database })
}

/// A resolver with no source: every domain resolves to the empty
/// configuration. Used by the mocked provider builder.
///
/// # Returns
/// - `Self` - A resolver that consults nothing.
pub fn disabled() -> Self {
Self {
file: None,
database: None,
}
}

/// The effective stored configuration for a domain: the file source
/// overlaid by the database source.
///
/// `%(option)s` references are not expanded and the global `[identity]` /
/// `[ldap]` sections are not applied — the result is the raw overlay the
/// config API serves. See the module note for the steps a driver-facing
/// consumer adds.
///
/// # Parameters
/// - `state`: The current service state, handed to each backend.
/// - `domain_id`: The ID of the domain to resolve.
///
/// # Returns
/// - `Result<DomainConfig, DomainConfigProviderError>` - The merged
/// configuration, empty when no source has one for the domain, or the
/// first error a source returns.
pub async fn effective_config(
&self,
state: &ServiceState,
domain_id: &str,
) -> Result<DomainConfig, DomainConfigProviderError> {
let mut resolved = DomainConfig::new();
if let Some(file) = &self.file
&& let Some(stored) = file.get_domain_config(state, domain_id).await?
{
resolved.overlay(&stored);
}
if let Some(database) = &self.database
&& let Some(stored) = database.get_domain_config(state, domain_id).await?
{
resolved.overlay(&stored);
}
Ok(resolved)
}
}

#[cfg(test)]
#[path = "resolver/tests.rs"]
mod tests;
Loading
Loading