diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 3aa723817..821003902 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -30,6 +30,7 @@ use openstack_keystone_core_types::auth::AuthenticationError; use openstack_keystone_core_types::auth_plugin_identity::AuthPluginIdentityProviderError; use openstack_keystone_core_types::catalog::CatalogProviderError; use openstack_keystone_core_types::credential::CredentialProviderError; +use openstack_keystone_core_types::domain_config::DomainConfigProviderError; use openstack_keystone_core_types::error::BuilderError; use openstack_keystone_core_types::error::KeystoneError; use openstack_keystone_core_types::identity::IdentityProviderError; @@ -384,6 +385,39 @@ impl From for KeystoneApiError { } } +impl From for KeystoneApiError { + fn from(value: DomainConfigProviderError) -> Self { + match value { + DomainConfigProviderError::NotFound { + domain_id, + group_or_option, + } => Self::NotFound { + resource: group_or_option, + identifier: domain_id, + }, + ref err @ DomainConfigProviderError::Conflict(..) => Self::Conflict(err.to_string()), + // A group or option that is not domain-configurable, or a write + // against the read-only filesystem driver, is a permissions + // statement rather than a malformed request. + err @ (DomainConfigProviderError::UnsupportedGroup(..) + | DomainConfigProviderError::UnsupportedOption { .. } + | DomainConfigProviderError::UnsupportedDriver(..) + | DomainConfigProviderError::Readonly(..)) => Self::forbidden(err), + // Everything else that is not a backend fault is a bad request: + // empty body, group/option shape mismatch, un-decodable value, + // failed validation. + err @ (DomainConfigProviderError::EmptyConfig + | DomainConfigProviderError::GroupMismatch(..) + | DomainConfigProviderError::GroupNotAMapping(..) + | DomainConfigProviderError::OptionWithoutGroup(..) + | DomainConfigProviderError::InvalidOptionValue { .. } + | DomainConfigProviderError::InvalidValue { .. } + | DomainConfigProviderError::Validation { .. }) => Self::BadRequest(err.to_string()), + other => Self::InternalError(other.to_string()), + } + } +} + impl From for KeystoneApiError { fn from(value: RevokeProviderError) -> Self { match value { @@ -692,6 +726,7 @@ impl From for KeystoneApiError { KeystoneError::Authentication { source } => source.into(), KeystoneError::CatalogProvider { source } => source.into(), KeystoneError::CredentialProvider { source } => source.into(), + KeystoneError::DomainConfigProvider { source } => source.into(), KeystoneError::FederationProvider { source } => source.into(), KeystoneError::Json { source } => source.into(), KeystoneError::K8sAuthProvider { source } => source.into(), diff --git a/crates/api-types/src/v3.rs b/crates/api-types/src/v3.rs index a91c6f611..7164dd8ea 100644 --- a/crates/api-types/src/v3.rs +++ b/crates/api-types/src/v3.rs @@ -16,6 +16,7 @@ pub mod application_credential; pub mod auth; pub mod credential; pub mod domain; +pub mod domain_config; pub mod ec2tokens; pub mod endpoint; pub mod group; @@ -36,6 +37,8 @@ mod auth_conv; #[cfg(feature = "conv")] mod credential_conv; #[cfg(feature = "conv")] +mod domain_config_conv; +#[cfg(feature = "conv")] mod domain_conv; #[cfg(feature = "conv")] mod endpoint_conv; diff --git a/crates/api-types/src/v3/domain_config.rs b/crates/api-types/src/v3/domain_config.rs new file mode 100644 index 000000000..a3dc12c98 --- /dev/null +++ b/crates/api-types/src/v3/domain_config.rs @@ -0,0 +1,50 @@ +// 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 API types. +//! +//! Every domain-configuration endpoint nests its payload under a single +//! `config` key. The value is an object of groups (`identity`, `ldap`), each an +//! object of option name to value: +//! +//! ```json +//! {"config": {"identity": {"driver": "ldap"}, +//! "ldap": {"url": "ldap://localhost", "user_tree_dn": "ou=Users,dc=example,dc=org"}}} +//! ``` +//! +//! Group- and option-scoped requests and responses use the same envelope with +//! only the addressed group (or option) present. Sensitive options +//! (`ldap.password`) may be written but are never echoed back in a response. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// `PUT` / `PATCH` body for the domain-configuration endpoints. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct DomainConfigRequest { + /// The configuration, an object of groups each mapping option name to + /// value. For a group- or option-scoped request only the addressed group + /// is present. + pub config: Value, +} + +/// Response body for the domain-configuration endpoints, including the +/// defaults endpoints. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct DomainConfigResponse { + /// The configuration, an object of groups each mapping option name to + /// value. Sensitive options are omitted. + pub config: Value, +} diff --git a/crates/api-types/src/v3/domain_config_conv.rs b/crates/api-types/src/v3/domain_config_conv.rs new file mode 100644 index 000000000..2edff1b6f --- /dev/null +++ b/crates/api-types/src/v3/domain_config_conv.rs @@ -0,0 +1,103 @@ +// 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 API types conversions. + +use serde_json::{Map, Value}; + +use openstack_keystone_core_types::domain_config as core; + +use crate::v3::domain_config as api_types; + +/// Serialize a value that only ever holds JSON objects and scalars, falling +/// back to an empty object on the impossible serialization failure (avoids +/// `unwrap` while keeping the conversions infallible). +fn to_object(value: impl serde::Serialize) -> Value { + serde_json::to_value(value).unwrap_or_else(|_| Value::Object(Map::new())) +} + +impl From for api_types::DomainConfigResponse { + fn from(value: core::DomainConfig) -> Self { + // `DomainConfig`'s `Serialize` already drops sensitive options. + Self { + config: to_object(&value), + } + } +} + +impl From for api_types::DomainConfigResponse { + fn from(value: core::DomainConfigGroup) -> Self { + let mut config = Map::new(); + config.insert(value.name().to_string(), to_object(&value)); + Self { + config: Value::Object(config), + } + } +} + +impl From for api_types::DomainConfigResponse { + fn from(value: core::DomainConfigOption) -> Self { + let mut options = Map::new(); + options.insert(value.option.clone(), value.value.as_value().clone()); + let mut config = Map::new(); + config.insert(value.group.to_string(), Value::Object(options)); + Self { + config: Value::Object(config), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use openstack_keystone_core_types::domain_config::{ + DomainConfig, DomainConfigGroupName, DomainConfigOption, + }; + + use super::*; + + #[test] + fn whole_config_response_drops_sensitive_options() { + let config = DomainConfig::from_value(json!({ + "ldap": {"url": "ldap://example", "password": "s3cr3t"} + })) + .expect("valid config"); + + let response = api_types::DomainConfigResponse::from(config); + + assert_eq!(response.config["ldap"]["url"], json!("ldap://example")); + assert!(response.config["ldap"].get("password").is_none()); + } + + #[test] + fn group_response_is_wrapped_under_the_group_name() { + let group = DomainConfig::from_value(json!({"ldap": {"url": "ldap://example"}})) + .expect("valid config") + .into_group(DomainConfigGroupName::Ldap) + .expect("ldap group"); + + let response = api_types::DomainConfigResponse::from(group); + + assert_eq!(response.config, json!({"ldap": {"url": "ldap://example"}})); + } + + #[test] + fn option_response_is_wrapped_under_group_and_option() { + let option = DomainConfigOption::new(DomainConfigGroupName::Identity, "driver", "sql"); + + let response = api_types::DomainConfigResponse::from(option); + + assert_eq!(response.config, json!({"identity": {"driver": "sql"}})); + } +} diff --git a/crates/core/src/domain_config/api.rs b/crates/core/src/domain_config/api.rs new file mode 100644 index 000000000..6603dd771 --- /dev/null +++ b/crates/core/src/domain_config/api.rs @@ -0,0 +1,153 @@ +// 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 provider API +//! +//! The subset of [`DomainConfigBackend`](crate::domain_config::DomainConfigBackend) +//! that backs the `/v3/domains/{domain_id}/config` REST endpoints: every verb +//! at the three granularities (whole configuration, single group, single +//! option), plus the read-only defaults. The registration lock methods of the +//! backend are intentionally excluded here -- they only matter once the stored +//! configuration drives identity-driver selection (issue #960). + +use async_trait::async_trait; + +use openstack_keystone_core_types::domain_config::*; + +use crate::domain_config::DomainConfigProviderError; +use crate::keystone::ServiceState; + +/// Domain configuration provider API. +/// +/// Signatures mirror the backend trait one-for-one so the service layer can +/// delegate straight through. See the backend trait for the sensitive-option +/// contract: `ldap.password` may be written but is never returned by the +/// group- or option-scoped getters. +#[async_trait] +pub trait DomainConfigApi: Send + Sync { + /// Replace the whole configuration of a domain + /// (`PUT /v3/domains/{domain_id}/config`). + async fn create_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigCreate, + ) -> Result; + + /// Get the whole configuration of a domain + /// (`GET /v3/domains/{domain_id}/config`). + async fn get_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result, DomainConfigProviderError>; + + /// Get a single configuration group of a domain + /// (`GET /v3/domains/{domain_id}/config/{group}`). + async fn get_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result, DomainConfigProviderError>; + + /// Get a single configuration option of a domain + /// (`GET /v3/domains/{domain_id}/config/{group}/{option}`). + async fn get_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError>; + + /// Merge changes into the whole configuration of a domain + /// (`PATCH /v3/domains/{domain_id}/config`). + async fn update_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigUpdate, + ) -> Result; + + /// Merge changes into a single configuration group of a domain + /// (`PATCH /v3/domains/{domain_id}/config/{group}`). + async fn update_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + config: DomainConfigUpdate, + ) -> Result; + + /// Change a single configuration option of a domain + /// (`PATCH /v3/domains/{domain_id}/config/{group}/{option}`). + async fn update_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + option: DomainConfigOption, + ) -> Result; + + /// Delete the whole configuration of a domain + /// (`DELETE /v3/domains/{domain_id}/config`). + async fn delete_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result<(), DomainConfigProviderError>; + + /// Delete a single configuration group of a domain + /// (`DELETE /v3/domains/{domain_id}/config/{group}`). + async fn delete_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result<(), DomainConfigProviderError>; + + /// Delete a single configuration option of a domain + /// (`DELETE /v3/domains/{domain_id}/config/{group}/{option}`). + async fn delete_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result<(), DomainConfigProviderError>; + + /// Get the global defaults every domain without configuration falls back + /// to (`GET /v3/domains/config/default`). + async fn get_default_config( + &self, + state: &ServiceState, + ) -> Result; + + /// Get the global defaults of a single group + /// (`GET /v3/domains/config/{group}/default`). + async fn get_default_group( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + ) -> Result; + + /// Get the global default of a single option + /// (`GET /v3/domains/config/{group}/{option}/default`). + async fn get_default_option<'a>( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError>; +} diff --git a/crates/core/src/domain_config/mod.rs b/crates/core/src/domain_config/mod.rs index b1ca5f8b8..cc82ec6af 100644 --- a/crates/core/src/domain_config/mod.rs +++ b/crates/core/src/domain_config/mod.rs @@ -24,10 +24,17 @@ //! single group, or a single option — which is why the backend trait carries a //! method triple for each verb. +pub mod api; pub mod backend; pub mod error; pub mod resolver; +pub mod service; +pub use api::DomainConfigApi; pub use backend::DomainConfigBackend; pub use error::DomainConfigProviderError; pub use resolver::DomainConfigResolver; +pub use service::DomainConfigService; + +#[cfg(any(test, feature = "mock"))] +pub use crate::mocks::MockDomainConfigProvider; diff --git a/crates/core/src/domain_config/service.rs b/crates/core/src/domain_config/service.rs new file mode 100644 index 000000000..9bf135948 --- /dev/null +++ b/crates/core/src/domain_config/service.rs @@ -0,0 +1,209 @@ +// 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 provider service + +use std::sync::Arc; + +use async_trait::async_trait; + +use openstack_keystone_config::Config; +use openstack_keystone_core_types::domain_config::*; + +use crate::domain_config::api::DomainConfigApi; +use crate::domain_config::backend::DomainConfigBackend; +use crate::domain_config::error::DomainConfigProviderError; +use crate::keystone::ServiceState; +use crate::plugin_manager::PluginManagerApi; + +/// The backend source the configuration API reads and writes. +/// +/// python-keystone's domain config API always targets the database, whatever +/// `[identity] domain_configurations_from_database` is set to -- that switch +/// only decides whether the *identity manager* consults the stored +/// configuration. The file source (`fs`) is operator-managed on disk and never +/// written through the API. +const API_BACKEND: &str = "sql"; + +/// Domain configuration provider service. +/// +/// A thin pass-through to the `sql` domain-config backend; every method +/// forwards verbatim. +pub struct DomainConfigService { + backend_driver: Arc, +} + +impl DomainConfigService { + /// Create a new `DomainConfigService`. + /// + /// # Parameters + /// - `_config`: The service configuration (unused: the API backend is + /// always `sql`). + /// - `plugin_manager`: The plugin manager used to resolve the backend + /// driver. + /// + /// # Returns + /// - `Result` - The initialized service, + /// or [`DomainConfigProviderError::UnsupportedDriver`] when the `sql` + /// backend is not registered. + pub fn new( + _config: &Config, + plugin_manager: &P, + ) -> Result { + let backend_driver = plugin_manager + .get_domain_config_backend(API_BACKEND)? + .clone(); + Ok(Self { backend_driver }) + } +} + +#[async_trait] +impl DomainConfigApi for DomainConfigService { + async fn create_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigCreate, + ) -> Result { + self.backend_driver + .create_domain_config(state, domain_id, config) + .await + } + + async fn get_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result, DomainConfigProviderError> { + self.backend_driver + .get_domain_config(state, domain_id) + .await + } + + async fn get_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result, DomainConfigProviderError> { + self.backend_driver + .get_domain_config_group(state, domain_id, group) + .await + } + + async fn get_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError> { + self.backend_driver + .get_domain_config_option(state, domain_id, group, option) + .await + } + + async fn update_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigUpdate, + ) -> Result { + self.backend_driver + .update_domain_config(state, domain_id, config) + .await + } + + async fn update_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + config: DomainConfigUpdate, + ) -> Result { + self.backend_driver + .update_domain_config_group(state, domain_id, group, config) + .await + } + + async fn update_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + option: DomainConfigOption, + ) -> Result { + self.backend_driver + .update_domain_config_option(state, domain_id, option) + .await + } + + async fn delete_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result<(), DomainConfigProviderError> { + self.backend_driver + .delete_domain_config(state, domain_id) + .await + } + + async fn delete_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result<(), DomainConfigProviderError> { + self.backend_driver + .delete_domain_config_group(state, domain_id, group) + .await + } + + async fn delete_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result<(), DomainConfigProviderError> { + self.backend_driver + .delete_domain_config_option(state, domain_id, group, option) + .await + } + + async fn get_default_config( + &self, + state: &ServiceState, + ) -> Result { + self.backend_driver.get_default_config(state).await + } + + async fn get_default_group( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + ) -> Result { + self.backend_driver.get_default_group(state, group).await + } + + async fn get_default_option<'a>( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError> { + self.backend_driver + .get_default_option(state, group, option) + .await + } +} diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 771b57bbf..df976218c 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -1860,3 +1860,108 @@ mod trust { } } pub use trust::MockTrustProvider; + +mod domain_config { + use super::*; + + use openstack_keystone_core_types::domain_config::*; + + use crate::domain_config::{DomainConfigApi, DomainConfigProviderError}; + + mock! { + pub DomainConfigProvider {} + + #[async_trait] + impl DomainConfigApi for DomainConfigProvider { + async fn create_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigCreate, + ) -> Result; + + async fn get_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result, DomainConfigProviderError>; + + async fn get_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result, DomainConfigProviderError>; + + async fn get_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError>; + + async fn update_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + config: DomainConfigUpdate, + ) -> Result; + + async fn update_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + config: DomainConfigUpdate, + ) -> Result; + + async fn update_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + option: DomainConfigOption, + ) -> Result; + + async fn delete_domain_config<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + ) -> Result<(), DomainConfigProviderError>; + + async fn delete_domain_config_group<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + ) -> Result<(), DomainConfigProviderError>; + + async fn delete_domain_config_option<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result<(), DomainConfigProviderError>; + + async fn get_default_config( + &self, + state: &ServiceState, + ) -> Result; + + async fn get_default_group( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + ) -> Result; + + async fn get_default_option<'a>( + &self, + state: &ServiceState, + group: DomainConfigGroupName, + option: &'a str, + ) -> Result, DomainConfigProviderError>; + } + } +} +pub use domain_config::MockDomainConfigProvider; diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index d9736882d..6020d2768 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -40,7 +40,10 @@ use crate::catalog::MockCatalogProvider; use crate::credential::CredentialApi; #[cfg(any(test, feature = "mock"))] use crate::credential::MockCredentialProvider; +use crate::domain_config::DomainConfigApi; use crate::domain_config::DomainConfigResolver; +#[cfg(any(test, feature = "mock"))] +use crate::domain_config::MockDomainConfigProvider; use crate::error::KeystoneError; use crate::federation::FederationApi; #[cfg(any(test, feature = "mock"))] @@ -113,6 +116,9 @@ pub struct Provider { catalog: Box, /// Credential provider. credential: Box, + /// Domain configuration provider: the CRUD surface behind the + /// `/v3/domains/{domain_id}/config` API (issue #959). + domain_config: Box, /// Per-domain configuration resolution layer (issue #958). Merges the /// file-based and database-stored domain configuration; not yet consumed /// at runtime. @@ -255,6 +261,12 @@ impl ProviderBuilder { new } + pub fn mock_domain_config(self, value: impl DomainConfigApi + 'static) -> Self { + let mut new = self; + new.domain_config = Some(Box::new(value)); + new + } + pub fn mock_revoke(self, value: impl RevokeApi + 'static) -> Self { let mut new = self; new.revoke = Some(Box::new(value)); @@ -331,6 +343,10 @@ impl Provider { cfg, plugin_manager, )?); + let domain_config = Box::new(crate::domain_config::DomainConfigService::new( + cfg, + plugin_manager, + )?); let domain_config_resolver = crate::domain_config::DomainConfigResolver::new(cfg, plugin_manager)?; let auth_plugin_identity = Box::new( @@ -388,6 +404,7 @@ impl Provider { assignment, catalog, credential, + domain_config, domain_config_resolver, auth_plugin_identity, federation, @@ -420,6 +437,7 @@ impl Provider { .mock_assignment(MockAssignmentProvider::default()) .mock_catalog(MockCatalogProvider::default()) .mock_credential(MockCredentialProvider::default()) + .mock_domain_config(MockDomainConfigProvider::default()) .mock_auth_plugin_identity(MockDynamicPluginIdentityProvider::default()) .mock_identity(MockIdentityProvider::default()) .mock_idmapping(MockIdMappingProvider::default()) @@ -466,6 +484,11 @@ impl Provider { &*self.credential } + /// Get the domain configuration provider (issue #959). + pub fn get_domain_config_provider(&self) -> &dyn DomainConfigApi { + &*self.domain_config + } + /// Get the domain configuration resolution layer (issue #958). pub fn get_domain_config_resolver(&self) -> &DomainConfigResolver { &self.domain_config_resolver diff --git a/crates/keystone/src/api/v3/domain_config/create.rs b/crates/keystone/src/api/v3/domain_config/create.rs new file mode 100644 index 000000000..9ef3144f3 --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/create.rs @@ -0,0 +1,192 @@ +// 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 +//! # Create domain configuration API + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::{DomainConfigRequest, DomainConfigResponse}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core_types::domain_config::{DomainConfig, DomainConfigCreate}; + +/// Create (replace) the whole configuration of a domain. +/// +/// Options absent from the body are removed, sensitive storage included. +#[utoipa::path( + put, + path = "/{domain_id}/config", + request_body = DomainConfigRequest, + responses( + (status = CREATED, description = "Stored configuration", body = DomainConfigResponse), + (status = 400, description = "Invalid configuration"), + (status = 403, description = "Forbidden"), + ), + tag = "domain_config" +)] +#[tracing::instrument(name = "api::v3::domain_config_create", level = "debug", skip(state))] +pub(super) async fn create( + Auth(user_auth): Auth, + Path(domain_id): Path, + State(state): State, + Json(req): Json, +) -> Result { + let config = DomainConfig::from_value(req.config)?; + config.validate()?; + + // The serialized form drops sensitive options, keeping bind passwords out + // of the policy input (security model, invariant 6). + let policy_config = serde_json::to_value(&config)?; + state + .policy_enforcer + .enforce( + "identity/domain_config/create", + &user_auth, + json!({"domain_id": domain_id, "config": policy_config}), + None, + ) + .await?; + + let stored = state + .provider + .get_domain_config_provider() + .create_domain_config(&state, &domain_id, DomainConfigCreate::from(config)) + .await?; + + Ok(( + StatusCode::CREATED, + Json(DomainConfigResponse::from(stored)), + )) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::super::test_support::*; + + fn request(body: serde_json::Value) -> Request { + Request::builder() + .method("PUT") + .uri("/did/config") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + + #[tokio::test] + async fn test_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_create_domain_config() + .withf(|_, domain_id, _| domain_id == "did") + .returning(|_, _, _| Ok(config(json!({"ldap": {"url": "ldap://stored"}})))); + + let state = get_mocked_state( + Provider::mocked_builder().mock_domain_config(mock), + true, + None, + ) + .await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(request(json!({"config": {"ldap": {"url": "ldap://in"}}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["config"]["ldap"]["url"], json!("ldap://stored")); + } + + #[tokio::test] + async fn test_forbidden() { + let state = get_mocked_state( + Provider::mocked_builder().mock_domain_config(no_backend_calls()), + false, + None, + ) + .await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(request(json!({"config": {"ldap": {"url": "ldap://in"}}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_unauthorized() { + let state = get_mocked_state( + Provider::mocked_builder().mock_domain_config(no_backend_calls()), + true, + None, + ) + .await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .uri("/did/config") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"x"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_empty_config_rejected() { + let state = get_mocked_state( + Provider::mocked_builder().mock_domain_config(no_backend_calls()), + true, + None, + ) + .await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(request(json!({"config": {}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/default.rs b/crates/keystone/src/api/v3/domain_config/default.rs new file mode 100644 index 000000000..5270d9415 --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/default.rs @@ -0,0 +1,326 @@ +// 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 +//! # Default domain configuration API +//! +//! The global defaults a domain without its own configuration falls back to. +//! Read-only, and served from the running service configuration rather than +//! from storage. + +use std::str::FromStr; + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::{Value, json}; + +use super::types::DomainConfigResponse; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core_types::domain_config::DomainConfigGroupName; + +/// Show every group's defaults. +#[utoipa::path( + get, + path = "/config/default", + responses( + (status = OK, description = "Default configuration", body = DomainConfigResponse), + (status = 403, description = "Forbidden"), + ), + tag = "domain_config" +)] +#[tracing::instrument(name = "api::v3::domain_config_default", level = "debug", skip(state))] +pub(super) async fn show( + Auth(user_auth): Auth, + State(state): State, +) -> Result { + state + .policy_enforcer + .enforce( + "identity/domain_config/get_default", + &user_auth, + Value::Null, + None, + ) + .await?; + + let defaults = state + .provider + .get_domain_config_provider() + .get_default_config(&state) + .await?; + + Ok((StatusCode::OK, Json(DomainConfigResponse::from(defaults)))) +} + +/// Show one group's defaults. +#[utoipa::path( + get, + path = "/config/{group}/default", + responses( + (status = OK, description = "Default group", body = DomainConfigResponse), + (status = 403, description = "Forbidden or unsupported group"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_default_group", + level = "debug", + skip(state) +)] +pub(super) async fn show_group( + Auth(user_auth): Auth, + Path(group): Path, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/get_default", + &user_auth, + json!({"group": group.as_str()}), + None, + ) + .await?; + + let defaults = state + .provider + .get_domain_config_provider() + .get_default_group(&state, group) + .await?; + + Ok((StatusCode::OK, Json(DomainConfigResponse::from(defaults)))) +} + +/// Show one option's default. +#[utoipa::path( + get, + path = "/config/{group}/{option}/default", + responses( + (status = OK, description = "Default option", body = DomainConfigResponse), + (status = 403, description = "Forbidden or unsupported option"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_default_option", + level = "debug", + skip(state) +)] +pub(super) async fn show_option( + Auth(user_auth): Auth, + Path((group, option)): Path<(String, String)>, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/get_default", + &user_auth, + json!({"group": group.as_str(), "option": option}), + None, + ) + .await?; + + let response = match state + .provider + .get_domain_config_provider() + .get_default_option(&state, group, &option) + .await? + { + Some(stored) => DomainConfigResponse::from(stored), + // python-keystone reports a whitelisted option with no configured + // default as a `null` value rather than a 404. + None => DomainConfigResponse { + config: json!({ group.as_str(): { &option: Value::Null } }), + }, + }; + + Ok((StatusCode::OK, Json(response))) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core_types::domain_config::{DomainConfigGroupName, DomainConfigOption}; + + use super::super::test_support::*; + + #[tokio::test] + async fn test_default_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_default_config() + .returning(|_| Ok(config(serde_json::json!({"identity": {"driver": "sql"}})))); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/default") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_default_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/default") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_default_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/default") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_default_group_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_default_group().returning(|_, _| { + Ok( + config(serde_json::json!({"ldap": {"url": "ldap://default"}})) + .into_group(DomainConfigGroupName::Ldap) + .expect("ldap group"), + ) + }); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/ldap/default") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + res["config"]["ldap"]["url"], + serde_json::json!("ldap://default") + ); + } + + #[tokio::test] + async fn test_default_option_null_when_unset() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_default_option() + .returning(|_, _, _| Ok(None)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/ldap/url/default") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(res["config"]["ldap"]["url"].is_null()); + } + + #[tokio::test] + async fn test_default_option_value() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_default_option() + .returning(|_, group, option| { + Ok(Some(DomainConfigOption::new( + group, + option.to_string(), + "ldap", + ))) + }); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/config/identity/driver/default") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + res["config"]["identity"]["driver"], + serde_json::json!("ldap") + ); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/delete.rs b/crates/keystone/src/api/v3/domain_config/delete.rs new file mode 100644 index 000000000..f3b29b4a4 --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/delete.rs @@ -0,0 +1,138 @@ +// 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 +//! # Delete domain configuration API + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; + +/// Delete the whole configuration of a domain. +#[utoipa::path( + delete, + path = "/{domain_id}/config", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Domain has no configuration"), + ), + tag = "domain_config" +)] +#[tracing::instrument(name = "api::v3::domain_config_delete", level = "debug", skip(state))] +pub(super) async fn remove( + Auth(user_auth): Auth, + Path(domain_id): Path, + State(state): State, +) -> Result { + state + .policy_enforcer + .enforce( + "identity/domain_config/delete", + &user_auth, + json!({"domain_id": domain_id}), + None, + ) + .await?; + + state + .provider + .get_domain_config_provider() + .delete_domain_config(&state, &domain_id) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core_types::domain_config::DomainConfigProviderError; + + use super::super::test_support::*; + + fn authed() -> Request { + Request::builder() + .method("DELETE") + .uri("/did/config") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap() + } + + #[tokio::test] + async fn test_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_delete_domain_config().returning(|_, _| Ok(())); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_not_found() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_delete_domain_config().returning(|_, _| { + Err(DomainConfigProviderError::NotFound { + domain_id: "did".into(), + group_or_option: "any options".into(), + }) + }); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/did/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/group.rs b/crates/keystone/src/api/v3/domain_config/group.rs new file mode 100644 index 000000000..d3ca164b5 --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/group.rs @@ -0,0 +1,343 @@ +// 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 +//! # Group-scoped domain configuration API + +use std::str::FromStr; + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::{DomainConfigRequest, DomainConfigResponse}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core_types::domain_config::{ + DomainConfig, DomainConfigGroupName, DomainConfigUpdate, +}; + +/// Show a single configuration group of a domain. +#[utoipa::path( + get, + path = "/{domain_id}/config/{group}", + responses( + (status = OK, description = "Stored group", body = DomainConfigResponse), + (status = 403, description = "Forbidden or unsupported group"), + (status = 404, description = "Group not stored for the domain"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_group_show", + level = "debug", + skip(state) +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path((domain_id, group)): Path<(String, String)>, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/show", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str()}), + None, + ) + .await?; + + match state + .provider + .get_domain_config_provider() + .get_domain_config_group(&state, &domain_id, group) + .await? + { + Some(stored) => Ok((StatusCode::OK, Json(DomainConfigResponse::from(stored)))), + None => Err(KeystoneApiError::NotFound { + resource: format!("domain config group {}", group.as_str()), + identifier: domain_id, + }), + } +} + +/// Merge changes into a single configuration group of a domain. +#[utoipa::path( + patch, + path = "/{domain_id}/config/{group}", + request_body = DomainConfigRequest, + responses( + (status = OK, description = "Resulting group", body = DomainConfigResponse), + (status = 400, description = "Invalid configuration"), + (status = 403, description = "Forbidden or unsupported group"), + (status = 404, description = "Group not stored for the domain"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_group_update", + level = "debug", + skip(state) +)] +pub(super) async fn update( + Auth(user_auth): Auth, + Path((domain_id, group)): Path<(String, String)>, + State(state): State, + Json(req): Json, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + let config = DomainConfig::from_value(req.config)?; + config.validate()?; + + let policy_config = serde_json::to_value(&config)?; + state + .policy_enforcer + .enforce( + "identity/domain_config/update", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str(), "config": policy_config}), + None, + ) + .await?; + + let stored = state + .provider + .get_domain_config_provider() + .update_domain_config_group(&state, &domain_id, group, DomainConfigUpdate::from(config)) + .await?; + + Ok((StatusCode::OK, Json(DomainConfigResponse::from(stored)))) +} + +/// Delete a single configuration group of a domain. +#[utoipa::path( + delete, + path = "/{domain_id}/config/{group}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden or unsupported group"), + (status = 404, description = "Group not stored for the domain"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_group_delete", + level = "debug", + skip(state) +)] +pub(super) async fn remove( + Auth(user_auth): Auth, + Path((domain_id, group)): Path<(String, String)>, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/delete", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str()}), + None, + ) + .await?; + + state + .provider + .get_domain_config_provider() + .delete_domain_config_group(&state, &domain_id, group) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use openstack_keystone_core_types::domain_config::DomainConfigGroupName; + + use super::super::test_support::*; + + fn ldap_group() -> openstack_keystone_core_types::domain_config::DomainConfigGroup { + config(json!({"ldap": {"url": "ldap://stored"}})) + .into_group(DomainConfigGroupName::Ldap) + .expect("ldap group") + } + + #[tokio::test] + async fn test_show_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_domain_config_group() + .returning(|_, _, _| Ok(Some(ldap_group()))); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config/ldap") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["config"]["ldap"]["url"], json!("ldap://stored")); + } + + #[tokio::test] + async fn test_show_unknown_group_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config/bogus") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_show_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config/ldap") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_update_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_update_domain_config_group() + .returning(|_, _, _, _| Ok(ldap_group())); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config/ldap") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"ldap://in"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_update_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config/ldap") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"ldap://in"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_delete_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_delete_domain_config_group() + .returning(|_, _, _| Ok(())); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/did/config/ldap") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/did/config/ldap") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/mod.rs b/crates/keystone/src/api/v3/domain_config/mod.rs new file mode 100644 index 000000000..a52e0ec6a --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/mod.rs @@ -0,0 +1,108 @@ +// 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 API +//! +//! Per-domain overrides of the identity backend configuration, exposed under +//! `/v3/domains`: +//! +//! - `/{domain_id}/config` — whole-configuration CRUD; +//! - `/{domain_id}/config/{group}` — one group; +//! - `/{domain_id}/config/{group}/{option}` — one option; +//! - `/config/default`, `/config/{group}/default`, +//! `/config/{group}/{option}/default` — the global defaults a domain without +//! its own configuration falls back to. +//! +//! The routes merge into the `domains` router (see the parent module). The +//! stored configuration is not yet consumed by identity-driver selection — +//! that is issue #960. + +use utoipa::OpenApi; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::keystone::ServiceState; + +mod create; +mod default; +mod delete; +mod group; +mod option; +mod show; +pub mod types; +mod update; + +/// OpenApi specification for the domain configuration API. +#[derive(OpenApi)] +#[openapi(tags(( + name = "domain_config", + description = "Per-domain identity backend configuration overrides." +)))] +pub struct ApiDoc; + +pub(crate) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!( + create::create, + show::show, + update::update, + delete::remove + )) + .routes(routes!(group::show, group::update, group::remove)) + .routes(routes!(option::show, option::update, option::remove)) + .routes(routes!(default::show)) + .routes(routes!(default::show_group)) + .routes(routes!(default::show_option)) +} + +#[cfg(test)] +pub(crate) mod test_support { + pub(crate) use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + pub(crate) use http_body_util::BodyExt; + pub(crate) use tower::ServiceExt; + pub(crate) use tower_http::trace::TraceLayer; + + pub(crate) use openstack_keystone_core::domain_config::MockDomainConfigProvider; + pub(crate) use openstack_keystone_core_types::domain_config::DomainConfig; + + pub(crate) use super::openapi_router; + pub(crate) use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + pub(crate) use crate::provider::Provider; + + /// A `DomainConfig` from a request-shaped object (`{"ldap": {...}}`). + pub(crate) fn config(body: serde_json::Value) -> DomainConfig { + DomainConfig::from_value(body).expect("a valid domain configuration") + } + + /// A `MockDomainConfigProvider` with no expectation set, for the + /// forbidden/unauthorized paths that never reach the provider. + pub(crate) fn no_backend_calls() -> MockDomainConfigProvider { + MockDomainConfigProvider::default() + } + + /// A mocked `ServiceState` whose domain-config provider is `mock` and + /// whose policy enforcer allows or denies per `allowed`. + pub(crate) async fn state( + mock: MockDomainConfigProvider, + allowed: bool, + ) -> crate::keystone::ServiceState { + get_mocked_state( + Provider::mocked_builder().mock_domain_config(mock), + allowed, + None, + ) + .await + } +} diff --git a/crates/keystone/src/api/v3/domain_config/option.rs b/crates/keystone/src/api/v3/domain_config/option.rs new file mode 100644 index 000000000..0ec0e831a --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/option.rs @@ -0,0 +1,361 @@ +// 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 +//! # Option-scoped domain configuration API + +use std::str::FromStr; + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::{DomainConfigRequest, DomainConfigResponse}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core_types::domain_config::{DomainConfigGroupName, DomainConfigOption}; + +/// Show a single configuration option of a domain. +#[utoipa::path( + get, + path = "/{domain_id}/config/{group}/{option}", + responses( + (status = OK, description = "Stored option", body = DomainConfigResponse), + (status = 403, description = "Forbidden or unsupported group"), + (status = 404, description = "Option not stored for the domain"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_option_show", + level = "debug", + skip(state) +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path((domain_id, group, option)): Path<(String, String, String)>, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/show", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str(), "option": option}), + None, + ) + .await?; + + match state + .provider + .get_domain_config_provider() + .get_domain_config_option(&state, &domain_id, group, &option) + .await? + { + Some(stored) => Ok((StatusCode::OK, Json(DomainConfigResponse::from(stored)))), + None => Err(KeystoneApiError::NotFound { + resource: format!("domain config option {}/{option}", group.as_str()), + identifier: domain_id, + }), + } +} + +/// Set a single configuration option of a domain. +#[utoipa::path( + patch, + path = "/{domain_id}/config/{group}/{option}", + request_body = DomainConfigRequest, + responses( + (status = OK, description = "Stored option", body = DomainConfigResponse), + (status = 400, description = "Invalid configuration"), + (status = 403, description = "Forbidden or unsupported group"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_option_update", + level = "debug", + skip(state) +)] +pub(super) async fn update( + Auth(user_auth): Auth, + Path((domain_id, group, option)): Path<(String, String, String)>, + State(state): State, + Json(req): Json, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + let value = req + .config + .get(group.as_str()) + .and_then(|options| options.get(&option)) + .cloned() + .ok_or_else(|| { + KeystoneApiError::BadRequest(format!( + "the config body must carry {}.{option}", + group.as_str() + )) + })?; + + let to_store = DomainConfigOption::new(group, option.clone(), value); + + // The value may be sensitive (`ldap.password`); never place it in the + // policy input (security model, invariant 6). + state + .policy_enforcer + .enforce( + "identity/domain_config/update", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str(), "option": option}), + None, + ) + .await?; + + let stored = state + .provider + .get_domain_config_provider() + .update_domain_config_option(&state, &domain_id, to_store) + .await?; + + Ok((StatusCode::OK, Json(DomainConfigResponse::from(stored)))) +} + +/// Delete a single configuration option of a domain. +#[utoipa::path( + delete, + path = "/{domain_id}/config/{group}/{option}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden or unsupported group"), + (status = 404, description = "Option not stored for the domain"), + ), + tag = "domain_config" +)] +#[tracing::instrument( + name = "api::v3::domain_config_option_delete", + level = "debug", + skip(state) +)] +pub(super) async fn remove( + Auth(user_auth): Auth, + Path((domain_id, group, option)): Path<(String, String, String)>, + State(state): State, +) -> Result { + let group = DomainConfigGroupName::from_str(&group)?; + + state + .policy_enforcer + .enforce( + "identity/domain_config/delete", + &user_auth, + json!({"domain_id": domain_id, "group": group.as_str(), "option": option}), + None, + ) + .await?; + + state + .provider + .get_domain_config_provider() + .delete_domain_config_option(&state, &domain_id, group, &option) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core_types::domain_config::{DomainConfigGroupName, DomainConfigOption}; + + use super::super::test_support::*; + + fn ldap_url_option() -> DomainConfigOption { + DomainConfigOption::new(DomainConfigGroupName::Ldap, "url", "ldap://stored") + } + + #[tokio::test] + async fn test_show_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_domain_config_option() + .returning(|_, _, _, _| Ok(Some(ldap_url_option()))); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + res["config"]["ldap"]["url"], + serde_json::json!("ldap://stored") + ); + } + + #[tokio::test] + async fn test_show_not_found() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_domain_config_option() + .returning(|_, _, _, _| Ok(None)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_update_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_update_domain_config_option() + .returning(|_, _, option| Ok(option)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"ldap://in"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["config"]["ldap"]["url"], serde_json::json!("ldap://in")); + } + + #[tokio::test] + async fn test_update_missing_value_bad_request() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"suffix":"dc=x"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_update_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"ldap://in"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_delete_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_delete_domain_config_option() + .returning(|_, _, _, _| Ok(())); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/did/config/ldap/url") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/did/config/ldap/url") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/show.rs b/crates/keystone/src/api/v3/domain_config/show.rs new file mode 100644 index 000000000..49afac9a2 --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/show.rs @@ -0,0 +1,142 @@ +// 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 +//! # Show domain configuration API + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::DomainConfigResponse; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; + +/// Show the whole configuration of a domain. +#[utoipa::path( + get, + path = "/{domain_id}/config", + responses( + (status = OK, description = "Stored configuration", body = DomainConfigResponse), + (status = 403, description = "Forbidden"), + (status = 404, description = "Domain has no configuration"), + ), + tag = "domain_config" +)] +#[tracing::instrument(name = "api::v3::domain_config_show", level = "debug", skip(state))] +pub(super) async fn show( + Auth(user_auth): Auth, + Path(domain_id): Path, + State(state): State, +) -> Result { + state + .policy_enforcer + .enforce( + "identity/domain_config/show", + &user_auth, + json!({"domain_id": domain_id}), + None, + ) + .await?; + + match state + .provider + .get_domain_config_provider() + .get_domain_config(&state, &domain_id) + .await? + { + Some(config) => Ok((StatusCode::OK, Json(DomainConfigResponse::from(config)))), + None => Err(KeystoneApiError::NotFound { + resource: "domain config".to_string(), + identifier: domain_id, + }), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::super::test_support::*; + + fn authed() -> Request { + Request::builder() + .uri("/did/config") + .extension(test_fixture_scoped()) + .body(Body::empty()) + .unwrap() + } + + #[tokio::test] + async fn test_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_domain_config() + .returning(|_, _| Ok(Some(config(json!({"ldap": {"url": "ldap://stored"}}))))); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["config"]["ldap"]["url"], json!("ldap://stored")); + } + + #[tokio::test] + async fn test_not_found() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_get_domain_config().returning(|_, _| Ok(None)); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api.as_service().oneshot(authed()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/did/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/domain_config/types.rs b/crates/keystone/src/api/v3/domain_config/types.rs new file mode 100644 index 000000000..3ea273dcc --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/types.rs @@ -0,0 +1,18 @@ +// 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 API types + +pub use openstack_keystone_api_types::v3::domain_config::{ + DomainConfigRequest, DomainConfigResponse, +}; diff --git a/crates/keystone/src/api/v3/domain_config/update.rs b/crates/keystone/src/api/v3/domain_config/update.rs new file mode 100644 index 000000000..79cba3dfc --- /dev/null +++ b/crates/keystone/src/api/v3/domain_config/update.rs @@ -0,0 +1,169 @@ +// 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 +//! # Update domain configuration API + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::{DomainConfigRequest, DomainConfigResponse}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core_types::domain_config::{DomainConfig, DomainConfigUpdate}; + +/// Merge changes into the whole configuration of a domain. +/// +/// Options absent from the body keep their stored value. +#[utoipa::path( + patch, + path = "/{domain_id}/config", + request_body = DomainConfigRequest, + responses( + (status = OK, description = "Resulting configuration", body = DomainConfigResponse), + (status = 400, description = "Invalid configuration"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Domain has no configuration"), + ), + tag = "domain_config" +)] +#[tracing::instrument(name = "api::v3::domain_config_update", level = "debug", skip(state))] +pub(super) async fn update( + Auth(user_auth): Auth, + Path(domain_id): Path, + State(state): State, + Json(req): Json, +) -> Result { + let config = DomainConfig::from_value(req.config)?; + config.validate()?; + + let policy_config = serde_json::to_value(&config)?; + state + .policy_enforcer + .enforce( + "identity/domain_config/update", + &user_auth, + json!({"domain_id": domain_id, "config": policy_config}), + None, + ) + .await?; + + let stored = state + .provider + .get_domain_config_provider() + .update_domain_config(&state, &domain_id, DomainConfigUpdate::from(config)) + .await?; + + Ok((StatusCode::OK, Json(DomainConfigResponse::from(stored)))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use openstack_keystone_core_types::domain_config::DomainConfigProviderError; + + use super::super::test_support::*; + + fn request(body: serde_json::Value) -> Request { + Request::builder() + .method("PATCH") + .uri("/did/config") + .extension(test_fixture_scoped()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + + #[tokio::test] + async fn test_allowed() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_update_domain_config() + .returning(|_, _, _| Ok(config(json!({"ldap": {"url": "ldap://merged"}})))); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot(request(json!({"config": {"ldap": {"url": "ldap://in"}}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(res["config"]["ldap"]["url"], json!("ldap://merged")); + } + + #[tokio::test] + async fn test_not_found() { + let mut mock = MockDomainConfigProvider::default(); + mock.expect_update_domain_config().returning(|_, _, _| { + Err(DomainConfigProviderError::NotFound { + domain_id: "did".into(), + group_or_option: "any options".into(), + }) + }); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(mock, true).await); + let response = api + .as_service() + .oneshot(request(json!({"config": {"ldap": {"url": "ldap://in"}}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_forbidden() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), false).await); + let response = api + .as_service() + .oneshot(request(json!({"config": {"ldap": {"url": "ldap://in"}}}))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_unauthorized() { + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state(no_backend_calls(), true).await); + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/did/config") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"config":{"ldap":{"url":"x"}}}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/mod.rs b/crates/keystone/src/api/v3/mod.rs index 1ad839102..802949b2c 100644 --- a/crates/keystone/src/api/v3/mod.rs +++ b/crates/keystone/src/api/v3/mod.rs @@ -30,6 +30,7 @@ use crate::keystone::ServiceState; pub mod auth; pub mod credential; pub mod domain; +pub mod domain_config; pub mod ec2tokens; pub mod endpoint; pub mod group; @@ -58,7 +59,10 @@ pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .nest("/auth", auth::openapi_router()) .nest("/credentials", credential::openapi_router()) - .nest("/domains", domain::openapi_router()) + .nest( + "/domains", + domain::openapi_router().merge(domain_config::openapi_router()), + ) .nest("/ec2tokens", ec2tokens::openapi_router()) .nest("/endpoints", endpoint::openapi_router()) .nest("/groups", group::openapi_router()) diff --git a/policy/domain_config/create.rego b/policy/domain_config/create.rego new file mode 100644 index 000000000..48e0f8c35 --- /dev/null +++ b/policy/domain_config/create.rego @@ -0,0 +1,32 @@ +# METADATA +# description: Policy for creating or replacing a domain's configuration +package identity.domain_config.create + +# Create (PUT) the whole configuration of a domain, a single group or a single +# option. +# +# `input.target.domain_id` is the domain being configured. +# `input.target.group` (optional) is the addressed group. +# `input.target.option` (optional) is the addressed option. +# `input.target.config` is the request body with sensitive options stripped. +# `input.existing` is null. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +# A domain manager may configure the domain their token is scoped to. +allow if { + "manager" in input.credentials.roles + input.credentials.domain_id == input.target.domain_id +} + +violation contains {"field": "", "msg": "writing a domain configuration requires system admin or the `manager` role on the domain."} if { + not allow +} diff --git a/policy/domain_config/create_test.rego b/policy/domain_config/create_test.rego new file mode 100644 index 000000000..2efaf6d3a --- /dev/null +++ b/policy/domain_config/create_test.rego @@ -0,0 +1,24 @@ +package test_domain_config_create + +import data.identity.domain_config.create + +test_admin_allowed if { + create.allow with input as {"credentials": {"roles": [], "is_admin": true}} + create.allow with input as {"credentials": {"roles": ["admin"], "is_admin": true}} +} + +test_domain_manager_allowed if { + create.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d1"}, + } +} + +test_forbidden if { + not create.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} + not create.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d2"}, + } + not create.allow with input as {"credentials": {"roles": []}} +} diff --git a/policy/domain_config/delete.rego b/policy/domain_config/delete.rego new file mode 100644 index 000000000..9efdbb203 --- /dev/null +++ b/policy/domain_config/delete.rego @@ -0,0 +1,31 @@ +# METADATA +# description: Policy for deleting a domain's configuration +package identity.domain_config.delete + +# Delete (DELETE) the whole configuration of a domain, a single group or a +# single option. +# +# `input.target.domain_id` is the domain being configured. +# `input.target.group` (optional) is the addressed group. +# `input.target.option` (optional) is the addressed option. +# `input.existing` is null. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +# A domain manager may configure the domain their token is scoped to. +allow if { + "manager" in input.credentials.roles + input.credentials.domain_id == input.target.domain_id +} + +violation contains {"field": "", "msg": "deleting a domain configuration requires system admin or the `manager` role on the domain."} if { + not allow +} diff --git a/policy/domain_config/delete_test.rego b/policy/domain_config/delete_test.rego new file mode 100644 index 000000000..c47e6d2f3 --- /dev/null +++ b/policy/domain_config/delete_test.rego @@ -0,0 +1,24 @@ +package test_domain_config_delete + +import data.identity.domain_config.delete + +test_admin_allowed if { + delete.allow with input as {"credentials": {"roles": [], "is_admin": true}} + delete.allow with input as {"credentials": {"roles": ["admin"], "is_admin": true}} +} + +test_domain_manager_allowed if { + delete.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d1"}, + } +} + +test_forbidden if { + not delete.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} + not delete.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d2"}, + } + not delete.allow with input as {"credentials": {"roles": []}} +} diff --git a/policy/domain_config/get_default.rego b/policy/domain_config/get_default.rego new file mode 100644 index 000000000..b699075ca --- /dev/null +++ b/policy/domain_config/get_default.rego @@ -0,0 +1,34 @@ +# METADATA +# description: Policy for reading the global domain configuration defaults +package identity.domain_config.get_default + +# Read (GET) the global defaults a domain without its own configuration falls +# back to. The defaults hold no secrets. +# +# `input.target.group` (optional) is the addressed group. +# `input.target.option` (optional) is the addressed option. +# `input.existing` is null. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +# Any authenticated reader may see the defaults. +allow if { + "reader" in input.credentials.roles +} + +# A domain manager may see the defaults. +allow if { + "manager" in input.credentials.roles +} + +violation contains {"field": "", "msg": "reading the domain configuration defaults requires an authenticated `reader`, `manager` or admin."} if { + not allow +} diff --git a/policy/domain_config/get_default_test.rego b/policy/domain_config/get_default_test.rego new file mode 100644 index 000000000..c97927201 --- /dev/null +++ b/policy/domain_config/get_default_test.rego @@ -0,0 +1,15 @@ +package test_domain_config_get_default + +import data.identity.domain_config.get_default + +test_authenticated_roles_allowed if { + get_default.allow with input as {"credentials": {"roles": [], "is_admin": true}} + get_default.allow with input as {"credentials": {"roles": ["admin"]}} + get_default.allow with input as {"credentials": {"roles": ["reader"]}} + get_default.allow with input as {"credentials": {"roles": ["manager"]}} +} + +test_no_role_forbidden if { + not get_default.allow with input as {"credentials": {"roles": []}} + not get_default.allow with input as {"credentials": {"roles": ["member"]}} +} diff --git a/policy/domain_config/show.rego b/policy/domain_config/show.rego new file mode 100644 index 000000000..633ffdc46 --- /dev/null +++ b/policy/domain_config/show.rego @@ -0,0 +1,37 @@ +# METADATA +# description: Policy for reading a domain's configuration +package identity.domain_config.show + +# Read (GET) the whole configuration of a domain, a single group or a single +# option. +# +# `input.target.domain_id` is the domain being read. +# `input.target.group` (optional) is the addressed group. +# `input.target.option` (optional) is the addressed option. +# `input.existing` is null. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +# A system reader may read any domain's configuration. +allow if { + "reader" in input.credentials.roles + input.credentials.system == "all" +} + +# A domain manager may read the domain their token is scoped to. +allow if { + "manager" in input.credentials.roles + input.credentials.domain_id == input.target.domain_id +} + +violation contains {"field": "", "msg": "reading a domain configuration requires system admin, the `reader` role with system scope, or the `manager` role on the domain."} if { + not allow +} diff --git a/policy/domain_config/show_test.rego b/policy/domain_config/show_test.rego new file mode 100644 index 000000000..96f77f8bb --- /dev/null +++ b/policy/domain_config/show_test.rego @@ -0,0 +1,28 @@ +package test_domain_config_show + +import data.identity.domain_config.show + +test_admin_allowed if { + show.allow with input as {"credentials": {"roles": [], "is_admin": true}} + show.allow with input as {"credentials": {"roles": ["admin"], "is_admin": true}} +} + +test_system_reader_allowed if { + show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} +} + +test_domain_manager_allowed if { + show.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d1"}, + } +} + +test_forbidden if { + not show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "d1"}} + not show.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d2"}, + } + not show.allow with input as {"credentials": {"roles": []}} +} diff --git a/policy/domain_config/update.rego b/policy/domain_config/update.rego new file mode 100644 index 000000000..1ad6a4227 --- /dev/null +++ b/policy/domain_config/update.rego @@ -0,0 +1,33 @@ +# METADATA +# description: Policy for merging changes into a domain's configuration +package identity.domain_config.update + +# Update (PATCH) the whole configuration of a domain, a single group or a +# single option. +# +# `input.target.domain_id` is the domain being configured. +# `input.target.group` (optional) is the addressed group. +# `input.target.option` (optional) is the addressed option. +# `input.target.config` (whole/group only) is the request body with sensitive +# options stripped. +# `input.existing` is null. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +# A domain manager may configure the domain their token is scoped to. +allow if { + "manager" in input.credentials.roles + input.credentials.domain_id == input.target.domain_id +} + +violation contains {"field": "", "msg": "writing a domain configuration requires system admin or the `manager` role on the domain."} if { + not allow +} diff --git a/policy/domain_config/update_test.rego b/policy/domain_config/update_test.rego new file mode 100644 index 000000000..e65a27a92 --- /dev/null +++ b/policy/domain_config/update_test.rego @@ -0,0 +1,24 @@ +package test_domain_config_update + +import data.identity.domain_config.update + +test_admin_allowed if { + update.allow with input as {"credentials": {"roles": [], "is_admin": true}} + update.allow with input as {"credentials": {"roles": ["admin"], "is_admin": true}} +} + +test_domain_manager_allowed if { + update.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d1"}, + } +} + +test_forbidden if { + not update.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} + not update.allow with input as { + "credentials": {"roles": ["manager"], "domain_id": "d1"}, + "target": {"domain_id": "d2"}, + } + not update.allow with input as {"credentials": {"roles": []}} +}