From e9fa4187fc9d1a7c8bc4c6ba1ba9f0110bd30dec Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 16 May 2026 20:26:48 +0200 Subject: [PATCH] Added support for organization-managed ERI servers --- .../Dialogs/DataSourceERI_V1Dialog.razor.cs | 1 + .../Settings/SettingsDialogDataSources.razor | 21 +- .../SettingsDialogDataSources.razor.cs | 6 + .../Pages/Information.razor | 1 + .../Plugins/configuration/plugin.lua | 48 +++++ .../DataSourceERIUsernamePasswordMode.cs | 19 ++ .../Settings/DataModel/DataSourceERI_V1.cs | 186 +++++++++++++++++- .../DataModel/DataSourceLocalDirectory.cs | 6 + .../Settings/DataModel/DataSourceLocalFile.cs | 6 + .../Settings/IDataSource.cs | 13 +- .../Settings/IERIDataSource.cs | 5 + .../Settings/IExternalDataSource.cs | 2 +- .../Tools/ERIClient/ERIClientV1.cs | 14 ++ .../PluginSystem/PendingEnterpriseApiKey.cs | 44 +++++ .../Tools/PluginSystem/PluginConfiguration.cs | 34 ++++ .../PluginSystem/PluginConfigurationObject.cs | 94 ++++++++- .../PluginSystem/PluginFactory.Loading.cs | 4 + .../Tools/SecretStoreType.cs | 2 +- .../Services/EnterpriseEnvironmentService.cs | 7 +- .../Tools/Services/RustService.OS.cs | 31 +++ .../Tools/Services/RustService.cs | 3 + .../wwwroot/changelog/v26.5.5.md | 3 +- runtime/Cargo.lock | 41 +++- runtime/Cargo.toml | 1 + runtime/src/environment.rs | 10 +- runtime/src/runtime_api.rs | 1 + 26 files changed, 582 insertions(+), 21 deletions(-) create mode 100644 app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs index 4a16bd18..4ee8bb1a 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs @@ -169,6 +169,7 @@ public partial class DataSourceERI_V1Dialog : MSGComponentBase, ISecretId Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname, AuthMethod = this.dataAuthMethod, Username = this.dataUsername, + UsernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED, Type = DataSourceType.ERI_V1, SecurityPolicy = this.dataSecurityPolicy, SelectedRetrievalId = this.dataSelectedRetrievalProcess.Id, diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor index 74b15fdb..3c9ec367 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor @@ -38,12 +38,21 @@ - - @T("Edit") - - - @T("Delete") - + @if (context.IsEnterpriseConfiguration) + { + + + + } + else + { + + @T("Edit") + + + @T("Delete") + + } diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs index c22bed94..e8792b1a 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor.cs @@ -89,6 +89,9 @@ public partial class SettingsDialogDataSources : SettingsDialogBase private async Task EditDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + IDataSource? editedDataSource = null; switch (dataSource) { @@ -151,6 +154,9 @@ public partial class SettingsDialogDataSources : SettingsDialogBase private async Task DeleteDataSource(IDataSource dataSource) { + if (dataSource.IsEnterpriseConfiguration) + return; + var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Are you sure you want to delete the data source '{0}' of type {1}?"), dataSource.Name, dataSource.Type.GetDisplayName()) }, diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 119611cb..b7f699a9 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -301,6 +301,7 @@ + diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index b4a942a7..6d2d51d3 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -136,6 +136,54 @@ CONFIG["EMBEDDING_PROVIDERS"] = {} -- } -- } +-- ERI v1 data sources for retrieval-augmented generation: +CONFIG["DATA_SOURCES"] = {} + +-- Example: ERI v1 data source with a shared access token. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "TOKEN", +-- ["Token"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source with a shared username and password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "SHARED_USERNAME_AND_PASSWORD", +-- ["Username"] = "", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + +-- Example: ERI v1 data source using the user's username and a shared password. +-- CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = { +-- ["Id"] = "00000000-0000-0000-0000-000000000000", +-- ["Name"] = "", +-- ["Type"] = "ERI_V1", +-- ["Hostname"] = "", +-- ["Port"] = 443, +-- ["AuthMethod"] = "USERNAME_PASSWORD", +-- ["UsernamePasswordMode"] = "OS_USERNAME_SHARED_PASSWORD", +-- ["Password"] = "ENC:v1:", +-- ["SecurityPolicy"] = "SELF_HOSTED", +-- ["SelectedRetrievalId"] = "", +-- ["MaxMatches"] = 10, +-- } + CONFIG["SETTINGS"] = {} -- Configure the update check interval: diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs new file mode 100644 index 00000000..67a86c41 --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERIUsernamePasswordMode.cs @@ -0,0 +1,19 @@ +namespace AIStudio.Settings.DataModel; + +public enum DataSourceERIUsernamePasswordMode +{ + /// + /// The user manages the username and password locally. + /// + USER_MANAGED, + + /// + /// The username and password are shared by all users and provided by configuration. + /// + SHARED_USERNAME_AND_PASSWORD, + + /// + /// The username is read from the operating system, and the password is shared by all users. + /// + OS_USERNAME_SHARED_PASSWORD, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs index cbc3839c..89b2cadd 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs @@ -2,11 +2,15 @@ using AIStudio.Assistants.ERI; using AIStudio.Chat; +using AIStudio.Tools; using AIStudio.Tools.ERIClient; using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; using AIStudio.Tools.Services; +using Lua; + using ChatThread = AIStudio.Chat.ChatThread; using ContentType = AIStudio.Tools.ERIClient.DataModel.ContentType; @@ -17,6 +21,8 @@ namespace AIStudio.Settings.DataModel; /// public readonly record struct DataSourceERI_V1 : IERIDataSource { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + public DataSourceERI_V1() { } @@ -45,8 +51,17 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource /// public string Username { get; init; } = string.Empty; + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } = DataSourceERIUsernamePasswordMode.USER_MANAGED; + /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ERIVersion Version { get; init; } = ERIVersion.V1; @@ -82,7 +97,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource Thread = await thread.ToERIChatThread(token), MaxMatches = this.MaxMatches, - RetrievalProcessId = string.IsNullOrWhiteSpace(this.SelectedRetrievalId) ? null : this.SelectedRetrievalId, + RetrievalProcessId = this.SelectedRetrievalId, Parameters = null, // The ERI server selects useful default parameters }; @@ -139,4 +154,173 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource logger.LogWarning($"Was not able to authenticate with the ERI data source '{this.Name}'. Message: {authResponse.Message}"); return []; } + + public static bool TryParseConfiguration(int idx, LuaTable table, Guid configPluginId, out DataSourceERI_V1 dataSource) + { + dataSource = default; + if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(out var idText) || !Guid.TryParse(idText, out var id)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead(out var name) || string.IsNullOrWhiteSpace(name)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid name. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Type", out var typeValue) || !typeValue.TryRead(out var typeText) || !Enum.TryParse(typeText, true, out var type) || type is not DataSourceType.ERI_V1) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a supported data source type. Only ERI_V1 is supported. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead(out var hostname) || string.IsNullOrWhiteSpace(hostname)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("Port", out var portValue) || !portValue.TryRead(out var port) || port is < 1 or > 65535) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid port. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("AuthMethod", out var authMethodValue) || !authMethodValue.TryRead(out var authMethodText) || !Enum.TryParse(authMethodText, true, out var authMethod)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid auth method. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SecurityPolicy", out var securityPolicyValue) || !securityPolicyValue.TryRead(out var securityPolicyText) || !Enum.TryParse(securityPolicyText, true, out var securityPolicy)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (securityPolicy is DataSourceSecurity.NOT_SPECIFIED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a security policy. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("SelectedRetrievalId", out var selectedRetrievalIdValue) || !selectedRetrievalIdValue.TryRead(out var selectedRetrievalId) || string.IsNullOrWhiteSpace(selectedRetrievalId)) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a selected retrieval ID. (Plugin ID: {configPluginId})"); + return false; + } + + if (!table.TryGetValue("MaxMatches", out var maxMatchesValue) || !maxMatchesValue.TryRead(out var maxMatches) || maxMatches is < 1 or > ushort.MaxValue) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid maximum number of matches. (Plugin ID: {configPluginId})"); + return false; + } + + var username = string.Empty; + var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED; + if (table.TryGetValue("UsernamePasswordMode", out var usernamePasswordModeValue) && usernamePasswordModeValue.TryRead(out var usernamePasswordModeText)) + { + if (!Enum.TryParse(usernamePasswordModeText, true, out usernamePasswordMode)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} uses the user-managed username/password mode. This mode is not allowed in configuration plugins. (Plugin ID: {configPluginId})"); + return false; + } + } + + if (authMethod is AuthMethod.USERNAME_PASSWORD) + { + if (!table.TryGetValue("UsernamePasswordMode", out _) || usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED) + { + LOGGER.LogWarning($"The configured data source {idx} must specify an organization-managed username/password mode. (Plugin ID: {configPluginId})"); + return false; + } + + if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD && + (!table.TryGetValue("Username", out var usernameValue) || !usernameValue.TryRead(out username) || string.IsNullOrWhiteSpace(username))) + { + LOGGER.LogWarning($"The configured data source {idx} must specify a username. (Plugin ID: {configPluginId})"); + return false; + } + } + + dataSource = new DataSourceERI_V1 + { + Num = 0, + Id = id.ToString(), + Name = name, + Type = DataSourceType.ERI_V1, + Hostname = CleanHostname(hostname), + Port = port, + AuthMethod = authMethod, + Username = username, + UsernamePasswordMode = usernamePasswordMode, + SecurityPolicy = securityPolicy, + Version = ERIVersion.V1, + SelectedRetrievalId = selectedRetrievalId, + MaxMatches = (ushort)maxMatches, + IsEnterpriseConfiguration = true, + EnterpriseConfigurationPluginId = configPluginId, + }; + + return TryQueueEnterpriseSecret(idx, table, configPluginId, dataSource); + } + + private static bool TryQueueEnterpriseSecret(int idx, LuaTable table, Guid configPluginId, DataSourceERI_V1 dataSource) + { + var secretFieldName = dataSource.AuthMethod switch + { + AuthMethod.TOKEN => "Token", + AuthMethod.USERNAME_PASSWORD => "Password", + _ => string.Empty, + }; + + if (string.IsNullOrWhiteSpace(secretFieldName)) + return true; + + if (!table.TryGetValue(secretFieldName, out var secretValue) || !secretValue.TryRead(out var encryptedSecret) || string.IsNullOrWhiteSpace(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} does not contain a valid encrypted {secretFieldName}. (Plugin ID: {configPluginId})"); + return false; + } + + if (!EnterpriseEncryption.IsEncrypted(encryptedSecret)) + { + LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})"); + return false; + } + + var encryption = PluginFactory.EnterpriseEncryption; + if (encryption?.IsAvailable != true) + { + LOGGER.LogWarning($"The configured data source {idx} contains an encrypted {secretFieldName}, but no encryption secret is configured. (Plugin ID: {configPluginId})"); + return false; + } + + if (!encryption.TryDecrypt(encryptedSecret, out var decryptedSecret)) + { + LOGGER.LogWarning($"Failed to decrypt the {secretFieldName} for data source {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})"); + return false; + } + + PendingEnterpriseSecrets.Add(new( + $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{dataSource.Id}", + dataSource.Name, + decryptedSecret)); + LOGGER.LogDebug($"Successfully decrypted the {secretFieldName} for data source {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})"); + return true; + } + + private static string CleanHostname(string hostname) + { + var cleanedHostname = hostname.Trim(); + return cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs index d8b263c3..a7531e74 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalDirectory.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalDirectory : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs index 11b857d0..0df0790f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceLocalFile.cs @@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalFile : IInternalDataSource /// public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED; + + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty; /// public ushort MaxMatches { get; init; } = 10; diff --git a/app/MindWork AI Studio/Settings/IDataSource.cs b/app/MindWork AI Studio/Settings/IDataSource.cs index 9ce3dc9f..39291ad2 100644 --- a/app/MindWork AI Studio/Settings/IDataSource.cs +++ b/app/MindWork AI Studio/Settings/IDataSource.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using AIStudio.Chat; using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.RAG; namespace AIStudio.Settings; @@ -13,7 +14,7 @@ namespace AIStudio.Settings; [JsonDerivedType(typeof(DataSourceLocalDirectory), nameof(DataSourceType.LOCAL_DIRECTORY))] [JsonDerivedType(typeof(DataSourceLocalFile), nameof(DataSourceType.LOCAL_FILE))] [JsonDerivedType(typeof(DataSourceERI_V1), nameof(DataSourceType.ERI_V1))] -public interface IDataSource +public interface IDataSource : IConfigurationObject { /// /// The number of the data source. @@ -39,6 +40,16 @@ public interface IDataSource /// Which data security policy is applied to this data source? /// public DataSourceSecurity SecurityPolicy { get; init; } + + /// + /// Is this data source an enterprise configuration? + /// + public bool IsEnterpriseConfiguration { get; init; } + + /// + /// The ID of the enterprise configuration plugin. + /// + public Guid EnterpriseConfigurationPluginId { get; init; } /// /// The maximum number of matches to return when retrieving data from the ERI server. diff --git a/app/MindWork AI Studio/Settings/IERIDataSource.cs b/app/MindWork AI Studio/Settings/IERIDataSource.cs index 55138978..5744ace8 100644 --- a/app/MindWork AI Studio/Settings/IERIDataSource.cs +++ b/app/MindWork AI Studio/Settings/IERIDataSource.cs @@ -24,6 +24,11 @@ public interface IERIDataSource : IExternalDataSource /// The username to use for authentication, when the auth. method is USERNAME_PASSWORD. /// public string Username { get; init; } + + /// + /// How username/password authentication should obtain the username. + /// + public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } /// /// The ERI specification to use. diff --git a/app/MindWork AI Studio/Settings/IExternalDataSource.cs b/app/MindWork AI Studio/Settings/IExternalDataSource.cs index 8a7c067c..8dd03718 100644 --- a/app/MindWork AI Studio/Settings/IExternalDataSource.cs +++ b/app/MindWork AI Studio/Settings/IExternalDataSource.cs @@ -7,7 +7,7 @@ public interface IExternalDataSource : IDataSource, ISecretId #region Implementation of ISecretId [JsonIgnore] - string ISecretId.SecretId => this.Id; + string ISecretId.SecretId => this.IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id; [JsonIgnore] string ISecretId.SecretName => this.Name; diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs index 2653ca2a..287be619 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientV1.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using AIStudio.Settings; +using AIStudio.Settings.DataModel; using AIStudio.Tools.ERIClient.DataModel; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Services; @@ -102,6 +103,19 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource), } case AuthMethod.USERNAME_PASSWORD: + if (this.DataSource.UsernamePasswordMode is DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD) + { + username = await rustService.ReadUserName(); + if (string.IsNullOrWhiteSpace(username)) + { + return new() + { + Successful = false, + Message = TB("Failed to read the user's username from the operating system.") + }; + } + } + string password; if (string.IsNullOrWhiteSpace(temporarySecret)) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs index 5f1cb58b..91d8ec04 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PendingEnterpriseApiKey.cs @@ -47,3 +47,47 @@ public static class PendingEnterpriseApiKeys } } } + +/// +/// Represents a pending enterprise secret that needs to be stored in the OS keyring. +/// +/// The secret ID. +/// The secret name. +/// The decrypted secret data. +public sealed record PendingEnterpriseSecret( + string SecretId, + string SecretName, + string SecretData); + +/// +/// Static container for pending enterprise secrets during plugin loading. +/// +public static class PendingEnterpriseSecrets +{ + private static readonly List PENDING_SECRETS = []; + private static readonly Lock LOCK = new(); + + /// + /// Adds a pending enterprise secret to the list. + /// + /// The pending enterprise secret to add. + public static void Add(PendingEnterpriseSecret secret) + { + lock (LOCK) + PENDING_SECRETS.Add(secret); + } + + /// + /// Gets and clears all pending enterprise secrets. + /// + /// A list of all pending enterprise secrets. + public static IReadOnlyList GetAndClear() + { + lock (LOCK) + { + var secrets = PENDING_SECRETS.ToList(); + PENDING_SECRETS.Clear(); + return secrets; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index da504b29..f5b09f1f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -39,12 +39,43 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT { // Store any decrypted API keys from enterprise configuration in the OS keyring: await StoreEnterpriseApiKeysAsync(); + await StoreEnterpriseSecretsAsync(); await SETTINGS_MANAGER.StoreSettings(); await MessageBus.INSTANCE.SendMessage(null, Event.CONFIGURATION_CHANGED); } } + /// + /// Stores any pending enterprise secrets in the OS keyring. + /// + private static async Task StoreEnterpriseSecretsAsync() + { + var pendingSecrets = PendingEnterpriseSecrets.GetAndClear(); + if (pendingSecrets.Count == 0) + return; + + LOG.LogInformation($"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring."); + var rustService = Program.SERVICE_PROVIDER.GetRequiredService(); + foreach (var pendingSecret in pendingSecrets) + { + try + { + var secretId = new TemporarySecretId(pendingSecret.SecretId, pendingSecret.SecretName); + var result = await rustService.SetSecret(secretId, pendingSecret.SecretData); + + if (result.Success) + LOG.LogDebug($"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring."); + else + LOG.LogWarning($"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}"); + } + catch (Exception ex) + { + LOG.LogError(ex, $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'."); + } + } + } + /// /// Stores any pending enterprise API keys in the OS keyring. /// @@ -152,6 +183,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Handle configured chat templates: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun); + + // Handle configured data sources: + PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun); // Handle configured profiles: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs index d0b299d3..8bd7ae0c 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfigurationObject.cs @@ -162,6 +162,87 @@ public sealed record PluginConfigurationObject return true; } + /// + /// Parses configured data sources from a configuration plugin. + /// + /// The Lua table containing entries to parse into data sources. + /// The unique identifier of the plugin associated with the data sources. + /// The list to populate with the parsed configuration objects. + /// Specifies whether to perform the operation as a dry run. + /// True if the table was present and processed; otherwise false. + public static bool TryParseDataSources( + LuaTable mainTable, + Guid configPluginId, + ref List configObjects, + bool dryRun) + { + const string LUA_TABLE_NAME = "DATA_SOURCES"; + if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead(out var luaTable)) + { + LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId); + return false; + } + + var storedObjects = SETTINGS_MANAGER.ConfigurationData.DataSources; + var numberObjects = luaTable.ArrayLength; + ThreadSafeRandom? random = null; + for (var i = 1; i <= numberObjects; i++) + { + var luaObjectTableValue = luaTable[i]; + if (!luaObjectTableValue.TryRead(out var luaObjectTable)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, out var configObject)) + { + LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId); + continue; + } + + configObjects.Add(new() + { + ConfigPluginId = configPluginId, + Id = Guid.Parse(configObject.Id), + Type = PluginConfigurationObjectType.DATA_SOURCE, + }); + + if (dryRun) + continue; + + var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id); + if (objectIndex > -1) + { + var existingObject = storedObjects[objectIndex]; + configObject = configObject with { Num = existingObject.Num }; + storedObjects[objectIndex] = configObject; + } + else + { + if (IncrementDataSourceNum() is { Success: true, UpdatedValue: var nextNum }) + { + configObject = configObject with { Num = nextNum }; + storedObjects.Add(configObject); + } + else + { + random ??= new ThreadSafeRandom(); + configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) }; + storedObjects.Add(configObject); + LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId); + } + } + } + + return true; + + static IncrementResult IncrementDataSourceNum() + { + return ((Expression>)(x => x.NextDataSourceNum)).TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST); + } + } + /// /// Cleans up configuration objects of a specified type that are no longer associated with any available plugin. /// @@ -177,7 +258,8 @@ public sealed record PluginConfigurationObject Expression>> configObjectSelection, IList availablePlugins, IList configObjectList, - SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject + SecretStoreType? secretStoreType = null, + bool deleteSecret = false) where TClass : IConfigurationObject { var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData); var leftOverObjects = new List(); @@ -220,7 +302,15 @@ public sealed record PluginConfigurationObject configuredObjects.Remove(item); // Delete the API key from the OS keyring if the removed object has one: - if(secretStoreType is not null && item is ISecretId secretId) + if(deleteSecret && item is ISecretId regularSecretId) + { + var deleteResult = await RUST_SERVICE.DeleteSecret(regularSecretId); + if (deleteResult.Success) + LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring."); + else + LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}"); + } + else if(secretStoreType is not null && item is ISecretId secretId) { var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value); if (deleteResult.Success) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index aedc7f7e..0b7147da 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -174,6 +174,10 @@ public static partial class PluginFactory if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER)) wasConfigurationChanged = true; + // Check data sources: + if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, deleteSecret: true)) + wasConfigurationChanged = true; + // Check chat templates: if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList)) wasConfigurationChanged = true; diff --git a/app/MindWork AI Studio/Tools/SecretStoreType.cs b/app/MindWork AI Studio/Tools/SecretStoreType.cs index c4382b7b..9ebcadcd 100644 --- a/app/MindWork AI Studio/Tools/SecretStoreType.cs +++ b/app/MindWork AI Studio/Tools/SecretStoreType.cs @@ -29,4 +29,4 @@ public enum SecretStoreType /// Image provider secrets. Uses the "image::" prefix. /// IMAGE_PROVIDER, -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs index 656d7358..cd564e9a 100644 --- a/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs +++ b/app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs @@ -263,7 +263,9 @@ public sealed class EnterpriseEnvironmentService(ILogger(), null, secretTargets); return secretTargets.ToList(); } private static void AddEnterpriseManagedSecretTargets( IEnumerable secrets, - SecretStoreType storeType, + SecretStoreType? storeType, ISet secretTargets) where TSecret : ISecretId, IConfigurationObject { foreach (var secret in secrets) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs index 0b81ccfe..9fd151e8 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs @@ -32,4 +32,35 @@ public sealed partial class RustService this.userLanguageLock.Release(); } } + + public async Task ReadUserName(bool forceRequest = false) + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + await this.userNameLock.WaitAsync(); + try + { + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName)) + return this.cachedUserName; + + var response = await this.http.GetAsync("/system/username"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to read the user name from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + var userName = (await response.Content.ReadAsStringAsync()).Trim(); + if (string.IsNullOrWhiteSpace(userName)) + return string.Empty; + + this.cachedUserName = userName; + return userName; + } + finally + { + this.userNameLock.Release(); + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 9f495adb..6bcef10c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -18,6 +18,7 @@ public sealed partial class RustService : BackgroundService private readonly HttpClient http; private readonly SemaphoreSlim userLanguageLock = new(1, 1); + private readonly SemaphoreSlim userNameLock = new(1, 1); private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { @@ -31,6 +32,7 @@ public sealed partial class RustService : BackgroundService private ILogger? logger; private Encryption? encryptor; private string? cachedUserLanguage; + private string? cachedUserName; private readonly string apiPort; private readonly string certificateFingerprint; @@ -91,6 +93,7 @@ public sealed partial class RustService : BackgroundService { this.http.Dispose(); this.userLanguageLock.Dispose(); + this.userNameLock.Dispose(); base.Dispose(); } diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index 1008fe32..993749d3 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -1,4 +1,5 @@ # v26.5.5, build 240 (2026-05-xx xx:xx UTC) +- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. - Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation. - Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. - Improved the Pandoc management and detection process to make it more reliable. @@ -9,4 +10,4 @@ - Upgraded Tauri to v2.11.1. - Upgraded PDFium to v148.0.7763.0. - Upgraded Qdrant to v1.18.0. -- Upgraded other dependencies as well. \ No newline at end of file +- Upgraded other dependencies as well. diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index c8894cac..ed6866a7 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2893,9 +2893,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2928,11 +2928,10 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", ] @@ -3097,6 +3096,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "whoami", "windows-native-keyring-store", "windows-registry", ] @@ -3549,6 +3549,15 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.0" @@ -6079,6 +6088,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.13.3+wasi-0.2.2", +] + [[package]] name = "wasm-bindgen" version = "0.2.120" @@ -6298,6 +6316,19 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 304d0332..3578b14f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -41,6 +41,7 @@ file-format = "0.29.0" calamine = "0.35.0" pdfium-render = "0.9.1" sys-locale = "0.3.2" +whoami = "2.1.2" cfg-if = "1.0.4" pptx-to-md = "0.4.0" tempfile = "3.27.0" diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 1e45b5f3..3f8dd43c 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -1,6 +1,6 @@ use crate::api_token::APIToken; use axum::Json; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::env; @@ -43,6 +43,14 @@ pub async fn get_data_directory(_token: APIToken) -> String { } } +/// Returns the current user's username. +pub async fn read_user_name(_token: APIToken) -> String { + whoami::username().unwrap_or_else(|e| { + error!("Failed to read the current OS username: {e}."); + String::new() + }) +} + /// Returns true if the application is running in development mode. pub fn is_dev() -> bool { cfg!(debug_assertions) diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 213c8a55..89f6cec0 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -48,6 +48,7 @@ pub fn start_runtime_api() { .route("/system/directories/config", get(crate::environment::get_config_directory)) .route("/system/directories/data", get(crate::environment::get_data_directory)) .route("/system/language", get(crate::environment::read_user_language)) + .route("/system/username", get(crate::environment::read_user_name)) .route("/system/enterprise/config/id", get(crate::environment::read_enterprise_env_config_id)) .route("/system/enterprise/config/server", get(crate::environment::read_enterprise_env_config_server_url)) .route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret))