Improve enterprise config to handle encryption-only sources

This commit is contained in:
Thorsten Sommer 2026-03-23 12:04:12 +01:00
parent 422b31b3f4
commit c1b4f19893
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
2 changed files with 134 additions and 13 deletions

View File

@ -23,13 +23,15 @@ So that MindWork AI Studio knows where to load which configuration, this informa
### Source order and fallback behavior ### Source order and fallback behavior
AI Studio does **not** merge the registry, policy files, and environment variables. Instead, it checks them in order and uses the **first source that contains at least one valid enterprise configuration**: AI Studio does **not** merge the registry, policy files, and environment variables. Instead, it checks them in order:
- **Windows:** Registry -> Policy files -> Environment variables - **Windows:** Registry -> Policy files -> Environment variables
- **Linux:** Policy files -> Environment variables - **Linux:** Policy files -> Environment variables
- **macOS:** Policy files -> Environment variables - **macOS:** Policy files -> Environment variables
The encryption secret follows the same rule. It is only used from the same source that provided the active enterprise configurations. For enterprise configurations, AI Studio uses the **first source that contains at least one valid enterprise configuration**.
For the encryption secret, AI Studio uses the **first source that contains a non-empty encryption secret**, even if that source does not contain any enterprise configuration IDs or server URLs. This allows secret-only setups during migration or on machines that only need encrypted API key support.
### Multiple configurations (recommended) ### Multiple configurations (recommended)
@ -226,7 +228,7 @@ You can include encrypted API keys in your configuration plugins for cloud provi
In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string. In AI Studio, enable the "Show administration settings" toggle in the app settings. Then click the "Generate encryption secret and copy to clipboard" button in the "Enterprise Administration" section. This generates a cryptographically secure 256-bit key and copies it to your clipboard as a base64 string.
2. **Deploy the encryption secret:** 2. **Deploy the encryption secret:**
Distribute the secret to all client machines using the same source you use for the enterprise configurations: Distribute the secret to all client machines using any supported enterprise source. The secret can be deployed on its own, even when no enterprise configuration IDs or server URLs are defined on that machine:
- Windows Registry / GPO: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret` - Windows Registry / GPO: `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret`
- Policy file: `config_encryption_secret.yaml` - Policy file: `config_encryption_secret.yaml`
- Environment fallback: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET` - Environment fallback: `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`

View File

@ -197,7 +197,7 @@ struct EnterpriseSourceData {
#[get("/system/enterprise/config/id")] #[get("/system/enterprise/config/id")]
pub fn read_enterprise_env_config_id(_token: APIToken) -> String { pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration ID."); debug!("Trying to read the effective enterprise configuration ID.");
resolve_effective_enterprise_source() resolve_effective_enterprise_config_source()
.configs .configs
.into_iter() .into_iter()
.next() .next()
@ -208,7 +208,7 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
#[get("/system/enterprise/config/server")] #[get("/system/enterprise/config/server")]
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String { pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration server URL."); debug!("Trying to read the effective enterprise configuration server URL.");
resolve_effective_enterprise_source() resolve_effective_enterprise_config_source()
.configs .configs
.into_iter() .into_iter()
.next() .next()
@ -219,21 +219,27 @@ pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
#[get("/system/enterprise/config/encryption_secret")] #[get("/system/enterprise/config/encryption_secret")]
pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String { pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String {
debug!("Trying to read the effective enterprise configuration encryption secret."); debug!("Trying to read the effective enterprise configuration encryption secret.");
resolve_effective_enterprise_source().encryption_secret resolve_effective_enterprise_secret_source().encryption_secret
} }
/// Returns all enterprise configurations from the effective source. /// Returns all enterprise configurations from the effective source.
#[get("/system/enterprise/configs")] #[get("/system/enterprise/configs")]
pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> { pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> {
info!("Trying to read the effective enterprise configurations."); info!("Trying to read the effective enterprise configurations.");
Json(resolve_effective_enterprise_source().configs) Json(resolve_effective_enterprise_config_source().configs)
} }
fn resolve_effective_enterprise_source() -> EnterpriseSourceData { fn resolve_effective_enterprise_config_source() -> EnterpriseSourceData {
select_effective_enterprise_source(gather_enterprise_sources()) select_effective_enterprise_config_source(gather_enterprise_sources())
} }
fn select_effective_enterprise_source(sources: Vec<EnterpriseSourceData>) -> EnterpriseSourceData { fn resolve_effective_enterprise_secret_source() -> EnterpriseSourceData {
select_effective_enterprise_secret_source(gather_enterprise_sources())
}
fn select_effective_enterprise_config_source(
sources: Vec<EnterpriseSourceData>,
) -> EnterpriseSourceData {
for source in sources { for source in sources {
if !source.configs.is_empty() { if !source.configs.is_empty() {
info!("Using enterprise configuration source '{}'.", source.source_name); info!("Using enterprise configuration source '{}'.", source.source_name);
@ -247,6 +253,22 @@ fn select_effective_enterprise_source(sources: Vec<EnterpriseSourceData>) -> Ent
EnterpriseSourceData::default() EnterpriseSourceData::default()
} }
fn select_effective_enterprise_secret_source(
sources: Vec<EnterpriseSourceData>,
) -> EnterpriseSourceData {
for source in sources {
if !source.encryption_secret.is_empty() {
info!("Using enterprise encryption-secret source '{}'.", source.source_name);
return source;
}
info!("Enterprise encryption-secret source '{}' did not provide a usable secret.", source.source_name);
}
info!("No enterprise source provided an enterprise encryption secret.");
EnterpriseSourceData::default()
}
fn gather_enterprise_sources() -> Vec<EnterpriseSourceData> { fn gather_enterprise_sources() -> Vec<EnterpriseSourceData> {
cfg_if::cfg_if! { cfg_if::cfg_if! {
if #[cfg(target_os = "windows")] { if #[cfg(target_os = "windows")] {
@ -624,7 +646,8 @@ fn normalize_enterprise_config_id(value: &str) -> Option<String> {
mod tests { mod tests {
use super::{ use super::{
linux_policy_directories_from_xdg, load_policy_values_from_directories, linux_policy_directories_from_xdg, load_policy_values_from_directories,
normalize_locale_tag, parse_enterprise_source_values, select_effective_enterprise_source, normalize_locale_tag, parse_enterprise_source_values,
select_effective_enterprise_config_source, select_effective_enterprise_secret_source,
EnterpriseConfig, EnterpriseSourceData, EnterpriseConfig, EnterpriseSourceData,
}; };
use std::collections::HashMap; use std::collections::HashMap;
@ -736,8 +759,8 @@ mod tests {
} }
#[test] #[test]
fn select_effective_enterprise_source_uses_first_source_with_configs_only() { fn select_effective_enterprise_config_source_uses_first_source_with_configs_only() {
let selected = select_effective_enterprise_source(vec![ let selected = select_effective_enterprise_config_source(vec![
EnterpriseSourceData { EnterpriseSourceData {
source_name: String::from("registry"), source_name: String::from("registry"),
configs: vec![EnterpriseConfig { configs: vec![EnterpriseConfig {
@ -761,6 +784,85 @@ mod tests {
assert_eq!(selected.configs.len(), 1); assert_eq!(selected.configs.len(), 1);
} }
#[test]
fn select_effective_enterprise_secret_source_allows_secret_only_source() {
let selected = select_effective_enterprise_secret_source(vec![
EnterpriseSourceData {
source_name: String::from("policy files"),
configs: Vec::new(),
encryption_secret: String::from("POLICY-SECRET"),
},
EnterpriseSourceData {
source_name: String::from("environment"),
configs: vec![EnterpriseConfig {
id: String::from(TEST_ID_B),
server_url: String::from("https://env.example.org"),
}],
encryption_secret: String::new(),
},
]);
assert_eq!(selected.source_name, "policy files");
assert_eq!(selected.encryption_secret, "POLICY-SECRET");
assert!(selected.configs.is_empty());
}
#[test]
fn select_effective_enterprise_secret_source_falls_back_independently_from_configs() {
let selected = select_effective_enterprise_secret_source(vec![
EnterpriseSourceData {
source_name: String::from("registry"),
configs: vec![EnterpriseConfig {
id: TEST_ID_A.to_lowercase(),
server_url: String::from("https://registry.example.org"),
}],
encryption_secret: String::new(),
},
EnterpriseSourceData {
source_name: String::from("environment"),
configs: Vec::new(),
encryption_secret: String::from("ENV-SECRET"),
},
]);
assert_eq!(selected.source_name, "environment");
assert_eq!(selected.encryption_secret, "ENV-SECRET");
assert!(selected.configs.is_empty());
}
#[test]
fn select_effective_enterprise_secret_source_ignores_empty_secrets() {
let selected = select_effective_enterprise_secret_source(vec![
EnterpriseSourceData {
source_name: String::from("policy files"),
configs: Vec::new(),
encryption_secret: String::new(),
},
EnterpriseSourceData {
source_name: String::from("environment"),
configs: Vec::new(),
encryption_secret: String::from("VALID-SECRET"),
},
]);
assert_eq!(selected.source_name, "environment");
assert_eq!(selected.encryption_secret, "VALID-SECRET");
}
#[test]
fn parse_enterprise_source_values_supports_secret_without_configs() {
let mut values = HashMap::new();
values.insert(
String::from("config_encryption_secret"),
String::from(" SECRET-ONLY "),
);
let source = parse_enterprise_source_values("environment variables", &values);
assert!(source.configs.is_empty());
assert_eq!(source.encryption_secret, "SECRET-ONLY");
}
#[test] #[test]
fn linux_policy_directories_from_xdg_preserves_order_and_falls_back() { fn linux_policy_directories_from_xdg_preserves_order_and_falls_back() {
assert_eq!( assert_eq!(
@ -869,6 +971,23 @@ mod tests {
); );
} }
#[test]
fn load_policy_values_from_directories_supports_secret_only_policy_files() {
let directory = tempdir().unwrap();
fs::write(
directory.path().join("config_encryption_secret.yaml"),
"config_encryption_secret: \"POLICY-SECRET\"",
)
.unwrap();
let values = load_policy_values_from_directories(&[directory.path().to_path_buf()]);
let source = parse_enterprise_source_values("policy files", &values);
assert!(source.configs.is_empty());
assert_eq!(source.encryption_secret, "POLICY-SECRET");
}
#[test] #[test]
fn load_policy_values_from_directories_ignores_invalid_and_incomplete_files() { fn load_policy_values_from_directories_ignores_invalid_and_incomplete_files() {
let directory = tempdir().unwrap(); let directory = tempdir().unwrap();