Added settings & features for administrators in organizations (#653)
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-02-07 22:59:41 +01:00
1 parent 5af6a8db3e
commit 4cf62672de
38 files changed
+1330 -90

No files matched your search

@@ -0,0 +1,49 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Represents a pending API key that needs to be stored in the OS keyring.
/// This is used during plugin loading to collect API keys from configuration plugins
/// before storing them asynchronously.
/// </summary>
/// <param name="SecretId">The secret ID (provider ID).</param>
/// <param name="SecretName">The secret name (provider instance name).</param>
/// <param name="ApiKey">The decrypted API key.</param>
/// <param name="StoreType">The type of secret store to use.</param>
public sealed record PendingEnterpriseApiKey(
string SecretId,
string SecretName,
string ApiKey,
SecretStoreType StoreType);
/// <summary>
/// Static container for pending API keys during plugin loading.
/// </summary>
public static class PendingEnterpriseApiKeys
{
private static readonly List<PendingEnterpriseApiKey> PENDING_KEYS = [];
private static readonly Lock LOCK = new();
/// <summary>
/// Adds a pending API key to the list.
/// </summary>
/// <param name="key">The pending API key to add.</param>
public static void Add(PendingEnterpriseApiKey key)
{
lock (LOCK)
PENDING_KEYS.Add(key);
}
/// <summary>
/// Gets and clears all pending API keys.
/// </summary>
/// <returns>A list of all pending API keys.</returns>
public static IReadOnlyList<PendingEnterpriseApiKey> GetAndClear()
{
lock (LOCK)
{
var keys = PENDING_KEYS.ToList();
PENDING_KEYS.Clear();
return keys;
}
}
}
@@ -1,4 +1,5 @@
using AIStudio.Settings;
using AIStudio.Tools.Services;
using Lua;
@@ -8,7 +9,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginConfiguration));
private List<PluginConfigurationObject> configObjects = [];
/// <summary>
@@ -23,11 +25,50 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
if (!dryRun)
{
// Store any decrypted API keys from enterprise configuration in the OS keyring:
await StoreEnterpriseApiKeysAsync();
await SETTINGS_MANAGER.StoreSettings();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
}
}
/// <summary>
/// Stores any pending enterprise API keys in the OS keyring.
/// </summary>
private static async Task StoreEnterpriseApiKeysAsync()
{
var pendingKeys = PendingEnterpriseApiKeys.GetAndClear();
if (pendingKeys.Count == 0)
return;
LOG.LogInformation($"Storing {pendingKeys.Count} enterprise API key(s) in the OS keyring.");
var rustService = Program.SERVICE_PROVIDER.GetRequiredService<RustService>();
foreach (var pendingKey in pendingKeys)
{
try
{
// Create a temporary secret ID object for storing the key:
var secretId = new TemporarySecretId(pendingKey.SecretId, pendingKey.SecretName);
var result = await rustService.SetAPIKey(secretId, pendingKey.ApiKey, pendingKey.StoreType);
if (result.Success)
LOG.LogDebug($"Successfully stored enterprise API key for '{pendingKey.SecretName}' in the OS keyring.");
else
LOG.LogWarning($"Failed to store enterprise API key for '{pendingKey.SecretName}': {result.Issue}");
}
catch (Exception ex)
{
LOG.LogError(ex, $"Exception while storing enterprise API key for '{pendingKey.SecretName}'.");
}
}
}
/// <summary>
/// Temporary implementation of ISecretId for storing enterprise API keys.
/// </summary>
private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId;
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
@@ -60,6 +101,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: allow the user to add providers?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun);
// Config: show administration settings?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);
// Config: preview features visibility
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.PreviewVisibility, this.Id, settingsTable, dryRun);
@@ -100,4 +144,4 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
message = string.Empty;
return true;
}
}
}
@@ -2,6 +2,7 @@ using System.Linq.Expressions;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
using Lua;
@@ -13,6 +14,7 @@ namespace AIStudio.Tools.PluginSystem;
/// </summary>
public sealed record PluginConfigurationObject
{
private static readonly RustService RUST_SERVICE = Program.SERVICE_PROVIDER.GetRequiredService<RustService>();
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger<PluginConfigurationObject>();
@@ -159,7 +161,7 @@ public sealed record PluginConfigurationObject
return true;
}
/// <summary>
/// Cleans up configuration objects of a specified type that are no longer associated with any available plugin.
/// </summary>
@@ -168,37 +170,45 @@ public sealed record PluginConfigurationObject
/// <param name="configObjectSelection">A selection expression to retrieve the configuration objects from the main configuration.</param>
/// <param name="availablePlugins">A list of currently available plugins.</param>
/// <param name="configObjectList">A list of all existing configuration objects.</param>
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
/// <returns>Returns true if the configuration was altered during cleanup; otherwise, false.</returns>
public static bool CleanLeftOverConfigurationObjects<TClass>(
public static async Task<bool> CleanLeftOverConfigurationObjects<TClass>(
PluginConfigurationObjectType configObjectType,
Expression<Func<Data, List<TClass>>> configObjectSelection,
IList<IAvailablePlugin> availablePlugins,
IList<PluginConfigurationObject> configObjectList) where TClass : IConfigurationObject
IList<PluginConfigurationObject> configObjectList,
SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject
{
var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData);
var leftOverObjects = new List<TClass>();
foreach (var configuredObject in configuredObjects)
{
// Only process objects that are based on enterprise configuration plugins (aka configuration plugins),
// as only those can be left over after a plugin was removed:
if(!configuredObject.IsEnterpriseConfiguration)
continue;
// From what plugin is this configuration object coming from?
var configObjectSourcePluginId = configuredObject.EnterpriseConfigurationPluginId;
if(configObjectSourcePluginId == Guid.Empty)
continue;
// Is the source plugin still available? If not, we can be pretty sure that this configuration object is left
// over and should be removed:
var templateSourcePlugin = availablePlugins.FirstOrDefault(plugin => plugin.Id == configObjectSourcePluginId);
if(templateSourcePlugin is null)
{
LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing the chat template from the settings.");
LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is based on a plugin that is not available anymore. Removing this object from the settings.");
leftOverObjects.Add(configuredObject);
}
// Is the configuration object still present in the configuration plugin? If not, it is also left over and should be removed:
if(!configObjectList.Any(configObject =>
configObject.Type == configObjectType &&
configObject.ConfigPluginId == configObjectSourcePluginId &&
configObject.Id.ToString() == configuredObject.Id))
{
LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the chat template from the settings.");
LOG.LogWarning($"The configured object '{configuredObject.Name}' (id={configuredObject.Id}) is not present in the configuration plugin anymore. Removing the object from the settings.");
leftOverObjects.Add(configuredObject);
}
}
@@ -206,8 +216,20 @@ public sealed record PluginConfigurationObject
// Remove collected items after enumeration to avoid modifying the collection during iteration:
var wasConfigurationChanged = leftOverObjects.Count > 0;
foreach (var item in leftOverObjects.Distinct())
{
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)
{
var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value);
if (deleteResult.Success)
LOG.LogInformation($"Successfully deleted API key for removed enterprise provider '{item.Name}' from the OS keyring.");
else
LOG.LogWarning($"Failed to delete API key for removed enterprise provider '{item.Name}' from the OS keyring: {deleteResult.Issue}");
}
}
return wasConfigurationChanged;
}
}
@@ -131,26 +131,26 @@ public static partial class PluginFactory
//
// Check LLM providers:
var wasConfigurationChanged = PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList);
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.LLM_PROVIDER);
// Check transcription providers:
if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER, x => x.TranscriptionProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.TRANSCRIPTION_PROVIDER))
wasConfigurationChanged = true;
// Check embedding providers:
if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
wasConfigurationChanged = true;
// Check chat templates:
if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList))
wasConfigurationChanged = true;
// Check profiles:
if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.PROFILE, x => x.Profiles, AVAILABLE_PLUGINS, configObjectList))
wasConfigurationChanged = true;
// Check document analysis policies:
if(PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList))
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY, x => x.DocumentAnalysis.Policies, AVAILABLE_PLUGINS, configObjectList))
wasConfigurationChanged = true;
// Check for a preselected profile:
@@ -168,6 +168,10 @@ public static partial class PluginFactory
// Check for users allowed to added providers:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for admin settings visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for preview visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.PreviewVisibility, AVAILABLE_PLUGINS))
@@ -253,4 +257,4 @@ public static partial class PluginFactory
return new NoPlugin("This plugin type is not supported yet. Please try again with a future version of AI Studio.");
}
}
}
}
@@ -18,6 +18,29 @@ public static partial class PluginFactory
public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN;
/// <summary>
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
/// </summary>
public static EnterpriseEncryption? EnterpriseEncryption { get; private set; }
/// <summary>
/// Initializes the enterprise encryption service by reading the encryption secret
/// from the Windows Registry or environment variables.
/// </summary>
/// <param name="rustService">The Rust service to use for reading the encryption secret.</param>
public static async Task InitializeEnterpriseEncryption(Services.RustService rustService)
{
LOG.LogInformation("Initializing enterprise encryption service...");
var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret();
var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger<EnterpriseEncryption>();
EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret);
if (EnterpriseEncryption.IsAvailable)
LOG.LogInformation("Enterprise encryption service is available.");
else
LOG.LogWarning("Enterprise encryption service is not available (no secret configured).");
}
/// <summary>
/// Set up the plugin factory. We will read the data directory from the settings manager.
/// Afterward, we will create the plugins directory and the internal plugin directory.