Fixed how the enterprise configuration handles empty and invalid IDs (#1143)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-31 15:47:06 +01:00
1 parent 652d3a509f
commit 43d202310b
5 files changed
+401 -15

No files matched your search

@@ -158,8 +158,9 @@ CONFIG["LLM_PROVIDERS"] = {}
-- -- or "ENC:v1:<base64-encoded encrypted data>", which every version of AI Studio reads. Both
-- -- formats use the same secret. DataApp.EnterpriseEncryptionExportFormat below decides which
-- -- one your exports write.
-- -- The encryption secret must be configured via:
-- -- The encryption secret must be configured via one of:
-- -- Windows Registry: HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\config_encryption_secret
-- -- Policy file: config_encryption_secret.yaml, in the folder of the other policy files
-- -- Environment variable: MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET
-- -- You can export an encrypted API key from an existing provider using the export button in the settings.
-- -- ["APIKey"] = "ENC:v2:<base64-encoded encrypted data>",
@@ -595,12 +596,14 @@ CONFIG["SETTINGS"] = {}
-- Configure the preselected provider.
-- It must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"].
-- Please note: using an empty string ("") will lock the preselected provider selection, even though no valid preselected provider is found.
-- Please note: an empty string ("") locks the selection to no preselected provider. This needs
-- AI Studio v26.11.1 or newer; older versions ignore the empty string and leave the choice to the user.
-- CONFIG["SETTINGS"]["DataApp.PreselectedProvider"] = "00000000-0000-0000-0000-000000000000"
-- Configure the preselected profile.
-- It must be one of the profile IDs defined in CONFIG["PROFILES"].
-- Please note: using an empty string ("") will lock the preselected profile selection, even though no valid preselected profile is found.
-- Please note: an empty string ("") locks the selection to no preselected profile. This needs
-- AI Studio v26.11.1 or newer; older versions ignore the empty string and leave the choice to the user.
-- CONFIG["SETTINGS"]["DataApp.PreselectedProfile"] = "00000000-0000-0000-0000-000000000000"
-- Configure chat-specific preselected options.
@@ -885,7 +888,9 @@ CONFIG["SETTINGS"] = {}
-- Configure the transcription provider for voice-to-text functionality.
-- It must be one of the transcription provider IDs defined in CONFIG["TRANSCRIPTION_PROVIDERS"].
-- Without a selected transcription provider, dictation and transcription features will be disabled.
-- Please note: using an empty string ("") will lock the selection and disable dictation/transcription.
-- Please note: an empty string ("") locks the selection to no transcription provider, which disables
-- dictation/transcription. This needs AI Studio v26.11.1 or newer; older versions ignore the empty
-- string and leave the choice to the user.
-- CONFIG["SETTINGS"]["DataApp.UseTranscriptionProvider"] = "00000000-0000-0000-0000-000000000000"
-- Configure the Opus bitrate used when normalizing uploaded audio/video for transcription.
@@ -1279,12 +1284,21 @@ CONFIG["SETTINGS"] = {}
--
-- IMPORTANT: A configuration plugin cannot fix the very first download of that same
-- configuration plugin. For bootstrapping enterprise configuration downloads, deploy
-- the equivalent environment variables before AI Studio starts:
-- the policy file external_http_custom_root_certificates.yaml, in the folder of the
-- other policy files, before AI Studio starts. It takes precedence over everything else:
--
-- enabled: true
-- bundle_path: "/path/in/sandbox/company-root-cas.pem"
-- allowed_hosts: "*.intra.example.org;data.example.org"
--
-- Alternatively, deploy the equivalent environment variables before AI Studio starts:
--
-- MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_ENABLED=true
-- MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH=/path/in/sandbox/company-root-cas.pem
-- MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS=*.intra.example.org;data.example.org
--
-- See https://github.com/MindWorkAI/AI-Studio/wiki/Configuration-Server#your-own-certificate-authorities
--
-- Replaces, does not merge: a configuration with a higher priority replaces the host
-- list completely. Deploy this setting in one configuration only, or repeat every host
-- of the base configuration. Otherwise, hosts of the base configuration silently stop
@@ -1332,7 +1346,7 @@ CONFIG["SETTINGS"] = {}
-- Replaces, does not merge: a configuration with a higher priority replaces the whole
-- table. Every configuration that sets this must therefore list all providers it wants
-- to cover. A partial table is not completed from the configuration below it, and the
-- providers left out fall back to the app default.
-- providers left out get the confidence level UNKNOWN.
-- CONFIG["SETTINGS"]["DataConfidence.CustomConfidenceScheme"] = {
-- ["OPEN_AI"] = "MODERATE",
-- ["ANTHROPIC"] = "MODERATE",
@@ -218,8 +218,24 @@ public static partial class ManagedConfiguration
{
// Case: the read string is a Guid:
case Guid:
//
// An empty text selects nothing, which is also the default of these settings. This is
// how an organization locks a selection to none, e.g., to switch off transcription:
//
if (string.IsNullOrWhiteSpace(configuredText))
{
successful = true;
configuredValue = string.Empty;
break;
}
successful = Guid.TryParse(configuredText, out var id);
configuredValue = successful ? id.ToString().ToLowerInvariant() : configuredText;
// A dry run comes before the actual one, so only the actual one reports the text:
if (!successful && !dryRun)
Log.LogWarning($"The configuration plugin '{configPluginId}' sets '{configuredText}' for the setting '{configMeta.SettingName}', which is no valid ID. Ignoring it.");
break;
// Case: the read string is just a string:
@@ -8,6 +8,8 @@
- Fixed the Agenda Planner assistant leaving out the location of your event when it suggests a social activity.
- Fixed the ERI Server assistant leaving out your description of the authentication process when it generates a server.
- Fixed the ERI Server assistant not writing some generated files to disk, such as docker-compose.yml. It now tells you about every file it could not write.
- Fixed configuration plugins being unable to lock the preselected provider, the preselected profile, or the transcription provider to none. An empty value now does this, as the example configuration plugin describes. Older versions of AI Studio ignore the empty value and leave the choice to your users.
- Fixed AI Studio loading none of your organization's configurations when one place, such as the Windows registry, named a configuration with an invalid ID. AI Studio now skips that entry and uses the configurations from the other places.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
- Upgraded MudBlazor to v9.12.0.
@@ -0,0 +1,202 @@
using System.Linq.Expressions;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Lua;
using Lua.Standard;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks how a configuration plugin sets a managed setting which holds the ID of a provider, such as
/// the preselected provider or the transcription provider.
/// </summary>
/// <remarks>
/// An empty text selects nothing, which is also the default of these settings, so an organization locks a
/// selection to none with it. The settings are reached through Program.SERVICE_PROVIDER, as in the app,
/// because a managed setting writes its value and its lock there. The managed state of each setting is
/// static, which is why this fixture does not run alongside others.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class ManagedGuidSettingTests
{
private static readonly Guid PLUGIN_ID = Guid.Parse("8b3f1c6e-2d94-4a07-b5e1-6f9c0a2d7e43");
private const string USERS_PROVIDER_ID = "0f4d9a2c-7b16-4e85-a3c9-1d5e8b7f2a60";
private const string ORGANIZATIONS_PROVIDER_ID = "4a7e2b9d-1c63-4f08-9e5a-b2d6c8f0e317";
private const string PRESELECTED_PROVIDER = "DataApp.PreselectedProvider";
private const string TRANSCRIPTION_PROVIDER = "DataApp.UseTranscriptionProvider";
private SettingsManager settingsManager = null!;
private ServiceProvider serviceProvider = null!;
private IServiceProvider previousServiceProvider = null!;
[SetUp]
public void CreateSettings()
{
// Creating the settings data registers every managed setting:
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
this.previousServiceProvider = Program.SERVICE_PROVIDER;
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
Program.SERVICE_PROVIDER = this.serviceProvider;
}
[TearDown]
public void RestoreApplicationState()
{
// The managed state of a setting outlives this fixture, so we release what the plugin took over:
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
Program.SERVICE_PROVIDER = this.previousServiceProvider;
this.serviceProvider.Dispose();
}
[TestCase(PRESELECTED_PROVIDER, "")]
[TestCase(PRESELECTED_PROVIDER, " ")]
[TestCase(TRANSCRIPTION_PROVIDER, "")]
public async Task AnEmptyIdLocksTheSelectionToNone(string settingName, string configuredText)
{
this.SetValue(settingName, USERS_PROVIDER_ID);
var processed = await ProcessAsync(settingName, configuredText, allowUserOverride: false);
var configMeta = GetMeta(settingName);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True, "An empty text is a valid choice: none.");
Assert.That(this.GetValue(settingName), Is.Empty);
Assert.That(configMeta.IsLocked, Is.True);
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(PLUGIN_ID));
});
}
[Test]
public async Task AnEmptyIdWithUserOverrideBecomesTheDefault()
{
this.SetValue(PRESELECTED_PROVIDER, USERS_PROVIDER_ID);
var processed = await ProcessAsync(PRESELECTED_PROVIDER, string.Empty, allowUserOverride: true);
var configMeta = GetMeta(PRESELECTED_PROVIDER);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True);
Assert.That(this.GetValue(PRESELECTED_PROVIDER), Is.Empty, "The default of the organization applies until the user chooses otherwise.");
Assert.That(configMeta.IsLocked, Is.False);
Assert.That(configMeta.ManagedMode, Is.EqualTo(ManagedConfigurationMode.EDITABLE_DEFAULT));
});
}
[Test]
public async Task AValidIdIsStoredInLowerCase()
{
var processed = await ProcessAsync(PRESELECTED_PROVIDER, ORGANIZATIONS_PROVIDER_ID.ToUpperInvariant(), allowUserOverride: false);
Assert.Multiple(() =>
{
Assert.That(processed, Is.True);
Assert.That(this.GetValue(PRESELECTED_PROVIDER), Is.EqualTo(ORGANIZATIONS_PROVIDER_ID));
Assert.That(GetMeta(PRESELECTED_PROVIDER).IsLocked, Is.True);
});
}
[Test]
public async Task AnInvalidIdIsIgnored()
{
this.SetValue(PRESELECTED_PROVIDER, USERS_PROVIDER_ID);
var processed = await ProcessAsync(PRESELECTED_PROVIDER, "not-a-guid", allowUserOverride: false);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False);
Assert.That(this.GetValue(PRESELECTED_PROVIDER), Is.EqualTo(USERS_PROVIDER_ID), "An invalid ID leaves the choice of the user untouched.");
Assert.That(GetMeta(PRESELECTED_PROVIDER).IsLocked, Is.False, "An invalid ID locks nothing.");
});
}
[Test]
public async Task TheLockEndsOnceThePluginNoLongerDeclaresTheSetting()
{
this.SetValue(PRESELECTED_PROVIDER, USERS_PROVIDER_ID);
await ProcessAsync(PRESELECTED_PROVIDER, string.Empty, allowUserOverride: false);
var processed = await ProcessAsync(PRESELECTED_PROVIDER, null, allowUserOverride: false);
Assert.Multiple(() =>
{
Assert.That(processed, Is.False);
Assert.That(GetMeta(PRESELECTED_PROVIDER).IsLocked, Is.False);
Assert.That(this.GetValue(PRESELECTED_PROVIDER), Is.EqualTo(USERS_PROVIDER_ID), "The user gets back the choice made before the plugin took the setting over.");
});
}
private string GetValue(string settingName) => PropertyOf(settingName).Compile().Invoke(this.settingsManager.ConfigurationData.App);
private void SetValue(string settingName, string value)
{
switch (settingName)
{
case PRESELECTED_PROVIDER:
this.settingsManager.ConfigurationData.App.PreselectedProvider = value;
break;
case TRANSCRIPTION_PROVIDER:
this.settingsManager.ConfigurationData.App.UseTranscriptionProvider = value;
break;
default:
throw new ArgumentOutOfRangeException(nameof(settingName), settingName, "This test knows no such setting.");
}
}
/// <summary>
/// Gets the managed state of a setting.
/// </summary>
private static ConfigMeta<DataApp, string> GetMeta(string settingName)
{
if (!ManagedConfiguration.TryGet(x => x.App, PropertyOf(settingName), out var configMeta))
throw new InvalidOperationException($"The setting '{settingName}' is not registered.");
return configMeta;
}
/// <summary>
/// Selects the property of a setting, the way PluginConfiguration hands it to ManagedConfiguration.
/// </summary>
private static Expression<Func<DataApp, string>> PropertyOf(string settingName) => settingName switch
{
PRESELECTED_PROVIDER => x => x.PreselectedProvider,
TRANSCRIPTION_PROVIDER => x => x.UseTranscriptionProvider,
_ => throw new ArgumentOutOfRangeException(nameof(settingName), settingName, "This test knows no such setting."),
};
/// <summary>
/// Lets a configuration plugin set a setting which holds an ID, as PluginConfiguration does.
/// </summary>
/// <param name="settingName">The name of the setting.</param>
/// <param name="configuredText">The text the plugin sets, or null for a plugin which does not declare the setting.</param>
/// <param name="allowUserOverride">True when the plugin offers the value as an editable default instead of locking it.</param>
/// <returns>Whether the plugin's value was taken.</returns>
private static async Task<bool> ProcessAsync(string settingName, string? configuredText, bool allowUserOverride)
{
var state = LuaState.Create();
state.OpenBasicLibrary();
var luaValue = configuredText is null ? "nil" : $"\"{configuredText}\"";
await state.DoStringAsync($$"""
SETTINGS = {}
SETTINGS["{{settingName}}"] = {{luaValue}}
SETTINGS["{{settingName}}.AllowUserOverride"] = {{(allowUserOverride ? "true" : "false")}}
""");
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
throw new InvalidOperationException("The settings of this test are not a Lua table.");
return ManagedConfiguration.TryProcessConfiguration(x => x.App, PropertyOf(settingName), Guid.Empty, PLUGIN_ID, settingsTable, false);
}
}
+161 -9
View File
@@ -254,11 +254,11 @@ fn detect_installation_kind() -> InstallationKind {
// The updater replaces the entire app bundle, so it needs to write into the directory that
// contains the bundle. On a device managed through an MDM solution like Jamf, the bundle sits
// in a location the user cannot write to. We deliberately do not look for a marker file here:
// any additional file inside the bundle would break its code signature. As a consequence, a
// macOS installation is never reported as managed, only as an unsupported location. An
// organization that wants AI Studio to name it explicitly sets DataApp.UpdateInterval to
// DISABLE_UPDATES in its enterprise configuration, which takes precedence anyway.
// in a location the user cannot write to, and update_target_installation_kind reports such an
// installation as managed. We deliberately do not look for a marker file here: any additional
// file inside the bundle would break its code signature. An organization that wants AI Studio to
// name it explicitly sets DataApp.UpdateInterval to DISABLE_UPDATES in its enterprise
// configuration, which takes precedence anyway.
match macos_app_bundle_directory(&executable_path) {
Some(bundle_directory) => update_target_installation_kind(&bundle_directory),
None => InstallationKind::User,
@@ -1303,11 +1303,18 @@ fn add_enterprise_config_pair(
configs: &mut Vec<EnterpriseConfig>,
seen_ids: &mut HashSet<String>,
) {
let id = raw_id.and_then(|value| normalize_enterprise_config_id(value.value()));
let id = raw_id.and_then(|value| normalize_enterprise_value(value.value()));
let server_url = raw_server_url.and_then(|value| normalize_enterprise_value(value.value()));
match (id, server_url) {
(Some(id), Some(server_url)) => {
// The app drops an ID which is no GUID. Taking the pair anyway would make this source the
// one we use, hide every source after it, and leave the device without any configuration:
let Some(id) = canonical_enterprise_config_id(&id) else {
warn!("Ignoring enterprise configuration from {} in '{}', because its ID '{}' is no valid GUID.", source_name, slot, id);
return;
};
if seen_ids.insert(id.clone()) {
configs.push(EnterpriseConfig {
id,
@@ -1368,14 +1375,50 @@ fn normalize_enterprise_value(value: &str) -> Option<String> {
}
}
fn normalize_enterprise_config_id(value: &str) -> Option<String> {
normalize_enterprise_value(value).map(|value| value.to_lowercase())
/// Returns the configuration ID in the form the app works with, lower case and with hyphens, or
/// `None` when it is no GUID.
///
/// The app reads the ID with `Guid.TryParse`. We accept the forms of it that IDs come in: with
/// hyphens, the same in braces or in parentheses, and 32 digits without hyphens. Writing every ID in
/// one form also lets the check for duplicates see the same ID in two forms.
fn canonical_enterprise_config_id(value: &str) -> Option<String> {
let enclosed = value
.strip_prefix('{')
.and_then(|inner| inner.strip_suffix('}'))
.or_else(|| value.strip_prefix('(').and_then(|inner| inner.strip_suffix(')')));
// Only the form with hyphens may be enclosed, as with Guid.TryParse:
let digits = match enclosed {
Some(inner) => digits_of_hyphenated_guid(inner)?,
None if value.len() == 32 => String::from(value),
None => digits_of_hyphenated_guid(value)?,
};
// Checking for hex digits also ensures that every character is a single byte, so that the slicing
// below cannot cut a character apart:
if digits.len() != 32 || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let digits = digits.to_ascii_lowercase();
Some(format!("{}-{}-{}-{}-{}", &digits[0..8], &digits[8..12], &digits[12..16], &digits[16..20], &digits[20..32]))
}
/// Returns the digits of a GUID written with hyphens, e.g., `9072b77d-ca81-40da-be6a-861da525ef7b`,
/// or `None` when its groups do not have the lengths of such a GUID. Whether the digits are hex
/// digits is left to the caller.
fn digits_of_hyphenated_guid(value: &str) -> Option<String> {
let groups: Vec<&str> = value.split('-').collect();
let has_guid_groups = groups.len() == 5
&& groups.iter().zip([8, 4, 4, 4, 12]).all(|(group, length)| group.len() == length);
has_guid_groups.then(|| groups.concat())
}
#[cfg(test)]
mod tests {
use super::{
directory_is_writable, enterprise_env_key_suffix_ignoring_case,
canonical_enterprise_config_id, directory_is_writable, enterprise_env_key_suffix_ignoring_case,
enterprise_environment_key_name, enterprise_policy_file_slot_suffix,
has_managed_installation_marker,
is_windows_machine_wide_installation, is_windows_per_user_installation,
@@ -1726,6 +1769,115 @@ mod tests {
assert_eq!(selected.configs.len(), 1);
}
/// A pair whose ID is no GUID must not make its source the one we use: the app drops the ID, and
/// the device would run without any configuration, although the next source has a valid one.
#[test]
fn select_effective_enterprise_config_source_skips_a_source_with_only_invalid_ids() {
let mut registry_values = HashMap::new();
registry_values.insert(String::from("config_id_00000"), String::from("not-a-guid"));
registry_values.insert(
String::from("config_server_url_00000"),
String::from("https://registry.example.org"),
);
let mut policy_values = HashMap::new();
policy_values.insert(String::from("config_id_00000"), String::from(TEST_ID_B));
policy_values.insert(
String::from("config_server_url_00000"),
String::from("https://policy.example.org"),
);
let selected = select_effective_enterprise_config_source(vec![
parse_enterprise_source_values("Windows registry", &registry_values),
parse_enterprise_source_values("policy files", &policy_values),
]);
assert_eq!(selected.source_name, "policy files");
assert_eq!(
selected.configs,
vec![enterprise_config(TEST_ID_B, "https://policy.example.org", "policy files", "", "indexed slot 00000")]
);
}
#[test]
fn parse_enterprise_source_values_skips_configs_with_invalid_ids() {
let mut values = HashMap::new();
values.insert(String::from("config_id0"), String::from("not-a-guid"));
values.insert(
String::from("config_server_url0"),
String::from("https://invalid.example.org"),
);
values.insert(String::from("config_id1"), String::from(TEST_ID_B));
values.insert(
String::from("config_server_url1"),
String::from("https://valid.example.org"),
);
values.insert(
String::from("configs"),
format!("not-a-guid@https://combined-invalid.example.org;{TEST_ID_C}@https://combined.example.org"),
);
let source = parse_enterprise_source_values("test", &values);
assert_eq!(
source.configs,
vec![
enterprise_config(TEST_ID_B, "https://valid.example.org", "test", "", "indexed slot 1"),
enterprise_config(TEST_ID_C, "https://combined.example.org", "test", "", "combined legacy entry 2"),
]
);
}
#[test]
fn parse_enterprise_source_values_detects_duplicates_across_id_forms() {
let mut values = HashMap::new();
values.insert(String::from("config_id0"), format!("{{{TEST_ID_A}}}"));
values.insert(
String::from("config_server_url0"),
String::from("https://braces.example.org"),
);
values.insert(String::from("config_id1"), TEST_ID_A.replace('-', ""));
values.insert(
String::from("config_server_url1"),
String::from("https://digits.example.org"),
);
let source = parse_enterprise_source_values("test", &values);
assert_eq!(
source.configs,
vec![enterprise_config("9072b77d-ca81-40da-be6a-861da525ef7b", "https://braces.example.org", "test", "", "indexed slot 0")]
);
}
#[test]
fn canonical_enterprise_config_id_accepts_the_forms_the_app_reads() {
let canonical = Some(String::from("9072b77d-ca81-40da-be6a-861da525ef7b"));
assert_eq!(canonical_enterprise_config_id("9072B77D-CA81-40DA-BE6A-861DA525EF7B"), canonical);
assert_eq!(canonical_enterprise_config_id("{9072b77d-ca81-40da-be6a-861da525ef7b}"), canonical);
assert_eq!(canonical_enterprise_config_id("(9072b77d-ca81-40da-be6a-861da525ef7b)"), canonical);
assert_eq!(canonical_enterprise_config_id("9072b77dca8140dabe6a861da525ef7b"), canonical);
}
#[test]
fn canonical_enterprise_config_id_rejects_other_forms() {
for id in [
"not-a-guid",
"9072b77d-ca81-40da-be6a-861da525ef7",
"9072b77d-ca81-40da-be6a-861da525ef7bb",
"9072b77g-ca81-40da-be6a-861da525ef7b",
"9072b77d-ca81-40da-be6a-861da525efä",
"9072b77dca8140dabe6a861da525ef7",
"{9072b77d-ca81-40da-be6a-861da525ef7b)",
"{9072b77dca8140dabe6a861da525ef7b}",
"urn:uuid:9072b77d-ca81-40da-be6a-861da525ef7b",
"{0x9072b77d,0xca81,0x40da,{0xbe,0x6a,0x86,0x1d,0xa5,0x25,0xef,0x7b}}",
] {
assert_eq!(canonical_enterprise_config_id(id), None, "'{id}' must not count as a configuration ID.");
}
}
#[test]
fn select_effective_enterprise_secret_source_allows_secret_only_source() {
let selected = select_effective_enterprise_secret_source(vec![