Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,22 @@ public static partial class PluginFactory

/// <summary>
/// Initializes the enterprise encryption service by reading the encryption secret
/// from the Windows Registry or environment variables.
/// from the effective enterprise source.
/// </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();
InitializeEnterpriseEncryption(encryptionSecret);
}

/// <summary>
/// Initializes the enterprise encryption service using a prefetched secret value.
/// </summary>
/// <param name="encryptionSecret">The base64-encoded enterprise encryption secret.</param>
public static void InitializeEnterpriseEncryption(string? encryptionSecret)
{
LOG.LogInformation("Initializing enterprise encryption service...");
var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger<EnterpriseEncryption>();
EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret);

Expand Down
111 changes: 110 additions & 1 deletion app/MindWork AI Studio/Tools/Services/EnterpriseEnvironmentService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
using AIStudio.Tools.PluginSystem;
using AIStudio.Settings;

using System.Security.Cryptography;
using System.Text;

namespace AIStudio.Tools.Services;

Expand All @@ -7,8 +11,14 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];

public static bool HasValidEnterpriseSnapshot { get; private set; }

private static EnterpriseSecretSnapshot CURRENT_SECRET_SNAPSHOT;

private readonly record struct EnterpriseEnvironmentSnapshot(Guid ConfigurationId, string ConfigurationServerUrl, string? ETag);

private readonly record struct EnterpriseSecretSnapshot(bool HasSecret, string Fingerprint);

private readonly record struct EnterpriseSecretTarget(string SecretId, string SecretName, SecretStoreType StoreType) : ISecretId;

#if DEBUG
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
Expand Down Expand Up @@ -39,6 +49,7 @@ private async Task StartUpdating(bool isFirstRun = false)
logger.LogInformation("Start updating of the enterprise environment.");
HasValidEnterpriseSnapshot = false;
var previousSnapshot = BuildNormalizedSnapshot(CURRENT_ENVIRONMENTS);
var previousSecretSnapshot = CURRENT_SECRET_SNAPSHOT;

//
// Step 1: Fetch all active configurations.
Expand All @@ -55,6 +66,21 @@ private async Task StartUpdating(bool isFirstRun = false)
return;
}

string enterpriseEncryptionSecret;
try
{
enterpriseEncryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise encryption secret from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigEncryptionSecret failed");
return;
}

var nextSecretSnapshot = await BuildSecretSnapshot(enterpriseEncryptionSecret);
var wasSecretChanged = previousSecretSnapshot != nextSecretSnapshot;

//
// Step 2: Determine ETags and build the list of reachable configurations.
// IMPORTANT: when one config server fails, we continue with the others.
Expand Down Expand Up @@ -169,10 +195,20 @@ private async Task StartUpdating(bool isFirstRun = false)
logger.LogInformation("AI Studio runs without any enterprise configurations.");

var effectiveSnapshot = BuildNormalizedSnapshot(effectiveEnvironments);

if (PluginFactory.IsInitialized && wasSecretChanged)
{
logger.LogInformation("The enterprise encryption secret changed. Refreshing the enterprise encryption service and reloading plugins.");
PluginFactory.InitializeEnterpriseEncryption(enterpriseEncryptionSecret);
await this.RemoveEnterpriseManagedApiKeysAsync();
await PluginFactory.LoadAll();
}

CURRENT_ENVIRONMENTS = effectiveEnvironments;
CURRENT_SECRET_SNAPSHOT = nextSecretSnapshot;
HasValidEnterpriseSnapshot = true;

if (!previousSnapshot.SequenceEqual(effectiveSnapshot))
if (!previousSnapshot.SequenceEqual(effectiveSnapshot) || wasSecretChanged)
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.ENTERPRISE_ENVIRONMENTS_CHANGED);
}
catch (Exception e)
Expand All @@ -193,8 +229,81 @@ private static List<EnterpriseEnvironmentSnapshot> BuildNormalizedSnapshot(IEnum
.ToList();
}

private static async Task<EnterpriseSecretSnapshot> BuildSecretSnapshot(string secret)
{
if (string.IsNullOrWhiteSpace(secret))
return new EnterpriseSecretSnapshot(false, string.Empty);

return new EnterpriseSecretSnapshot(true, await ComputeSecretFingerprint(secret));
}

private static async Task<string> ComputeSecretFingerprint(string secret)
{
using var secretStream = new MemoryStream(Encoding.UTF8.GetBytes(secret));
var hash = await SHA256.HashDataAsync(secretStream);
return Convert.ToHexString(hash);
}

private static string NormalizeServerUrl(string serverUrl)
{
return serverUrl.Trim().TrimEnd('/');
}

private async Task RemoveEnterpriseManagedApiKeysAsync()
{
var secretTargets = GetEnterpriseManagedSecretTargets();
if (secretTargets.Count == 0)
{
logger.LogInformation("No enterprise-managed API keys are currently known in the settings. No keyring cleanup is required.");
return;
}

logger.LogInformation("Removing {SecretCount} enterprise-managed API key(s) from the OS keyring after an enterprise encryption secret change.", secretTargets.Count);
foreach (var target in secretTargets)
{
try
{
var deleteResult = await rustService.DeleteAPIKey(target, target.StoreType);
if (deleteResult.Success)
{
if (deleteResult.WasEntryFound)
logger.LogInformation("Successfully deleted enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
else
logger.LogInformation("Enterprise-managed API key '{SecretName}' was already absent from the OS keyring.", target.SecretName);
}
else
logger.LogWarning("Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring: {Issue}", target.SecretName, deleteResult.Issue);
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
}
}
}

private static List<EnterpriseSecretTarget> GetEnterpriseManagedSecretTargets()
{
var configurationData = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>().ConfigurationData;
var secretTargets = new HashSet<EnterpriseSecretTarget>();

AddEnterpriseManagedSecretTargets(configurationData.Providers, SecretStoreType.LLM_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.EmbeddingProviders, SecretStoreType.EMBEDDING_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.TranscriptionProviders, SecretStoreType.TRANSCRIPTION_PROVIDER, secretTargets);

return secretTargets.ToList();
}

private static void AddEnterpriseManagedSecretTargets<TSecret>(
IEnumerable<TSecret> secrets,
SecretStoreType storeType,
ISet<EnterpriseSecretTarget> secretTargets) where TSecret : ISecretId, IConfigurationObject
{
foreach (var secret in secrets)
{
if (!secret.IsEnterpriseConfiguration || secret.EnterpriseConfigurationPluginId == Guid.Empty)
continue;

secretTargets.Add(new EnterpriseSecretTarget(secret.SecretId, secret.SecretName, storeType));
}
}
}
1 change: 1 addition & 0 deletions app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Added a start-page setting, so AI Studio can now open directly on your preferred page when the app starts. Configuration plugins can also provide and optionally lock this default for organizations.
- Added math rendering in chats for LaTeX display formulas, including block formats such as `$$ ... $$` and `\[ ... \]`.
- Released the document analysis assistant after an intense testing phase.
- Improved enterprise deployment for organizations: administrators can now provide up to 10 centrally managed enterprise configuration slots, use policy files on Linux and macOS, and continue using older configuration formats as a fallback during migration.
- Improved the profile selection for assistants and the chat. You can now explicitly choose between the app default profile, no profile, or a specific profile.
- Improved the performance by caching the OS language detection and requesting the user language only once per app start.
- Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations.
Expand Down
Loading
Loading