Improved enterprise plugin rollouts and protected deployed plugins against changes (#938)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-28 14:06:24 +02:00
1 parent a4c35fc2f2
commit 3940ad7f29
11 files changed
+232 -54

No files matched your search

@@ -133,38 +133,58 @@ public static partial class PluginFactory
AVAILABLE_PLUGINS.Remove(duplicatePlugin);
}
var isConfigurationPluginInConfigDirectory = plugin.Type is PluginType.CONFIGURATION && IsEnterpriseConfigurationPath(pluginPath);
var isManagedByConfigServer = false;
//
// An organization may deploy any kind of plugin, not just configurations: the
// archive it serves under a configuration ID often carries an assistant plugin
// in a subdirectory as well. Everything stored below one of the organization's
// directories therefore belongs to that organization, whatever its type is and
// however deeply it is nested:
//
var isInOrganizationDirectory = IsOrganizationConfigurationPath(pluginPath);
Guid? managedConfigurationId = null;
var configurationPriority = 0;
bool? declaredAsManagedByConfigServer = null;
if (plugin is PluginConfiguration configPlugin)
{
configurationPriority = configPlugin.Priority;
if (configPlugin.DeployedUsingConfigServer.HasValue)
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
else if (isConfigurationPluginInConfigDirectory)
{
isManagedByConfigServer = true;
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
}
declaredAsManagedByConfigServer = configPlugin.DeployedUsingConfigServer;
}
else if (plugin is PluginAssistants assistantPlugin)
isManagedByConfigServer = assistantPlugin.IsManagedByConfigServer;
else if (plugin is PluginAssistants { HasDeploymentManagementMetadata: true } assistantPlugin)
declaredAsManagedByConfigServer = assistantPlugin.IsManagedByConfigServer;
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
// (the directory name under which the plugin was downloaded):
if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer)
//
// The plugin path outranks what a plugin declares about itself. A plugin an
// organization deployed could otherwise deny it and escape the withdrawal of that
// configuration, while keeping every right the directory grants it:
//
var isManagedByConfigServer = isInOrganizationDirectory || declaredAsManagedByConfigServer is true;
switch (declaredAsManagedByConfigServer)
{
var directoryName = Path.GetFileName(pluginPath);
if (Guid.TryParse(directoryName, out var enterpriseConfigId))
case null when isInOrganizationDirectory:
LOG.LogWarning($"The {plugin.Type} plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{pluginPath}'.");
break;
case false when isInOrganizationDirectory:
LOG.LogWarning($"The {plugin.Type} plugin '{plugin.Id}' declares 'DEPLOYED_USING_CONFIG_SERVER = false', but it is stored under '{pluginPath}' and therefore belongs to your organization. Treating it as managed. Please fix the plugin.");
break;
}
//
// Which configuration a plugin was deployed with is what ties it to the archive it
// came from. Only the configuration plugin itself must carry the configuration ID
// as its own ID: a plugin deployed alongside it has an ID of its own:
//
if (IsEnterpriseConfigurationPath(pluginPath))
{
if (TryGetDeployedConfigurationId(pluginPath, out var enterpriseConfigId))
{
managedConfigurationId = enterpriseConfigId;
if (enterpriseConfigId != plugin.Id)
if (plugin.Type is PluginType.CONFIGURATION && enterpriseConfigId != plugin.Id)
LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID.");
}
else
LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID.");
LOG.LogWarning($"Could not determine the managed configuration ID for the {plugin.Type} plugin '{plugin.Id}'. The plugin directory '{pluginPath}' is not nested in a directory named after a configuration ID.");
}
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId, configurationPriority));
@@ -212,9 +232,28 @@ public static partial class PluginFactory
foreach (var testConfigurationPlugin in AVAILABLE_PLUGINS.Where(plugin => plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath)))
deployedEnterpriseConfigPluginIds.Add(testConfigurationPlugin.Id);
//
// A deployment does not have to contain a configuration plugin under its own ID: an
// organization uses the same channel to roll out assistant plugins and other plugin types.
// We therefore collect which deployments contributed a plugin at all, so that such a rollout
// is not mistaken for a configuration nobody could read:
//
var configurationIdsWithLoadedPlugins = AVAILABLE_PLUGINS
.Where(plugin => plugin.ManagedConfigurationId.HasValue)
.Select(plugin => plugin.ManagedConfigurationId!.Value)
.ToHashSet();
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
{
if (configurationIdsWithLoadedPlugins.Contains(unloadedEnterpriseConfigPluginId))
{
LOG.LogInformation($"The deployment '{unloadedEnterpriseConfigPluginId}' contains no configuration plugin of its own, but other plugins your organization deployed with it were loaded. Should you expect a configuration plugin here, please check the errors above.");
continue;
}
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
}
// Check LLM providers:
var wasConfigurationChanged = await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, AVAILABLE_PLUGINS, deployedEnterpriseConfigPluginIds, configObjectList, SecretStoreType.LLM_PROVIDER);
@@ -13,24 +13,33 @@ public static partial class PluginFactory
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
/// <summary>
/// The directory the config server downloads the configuration plugins of an organization into.
/// The directory the config server downloads the plugins of an organization into.
/// </summary>
/// <remarks>
/// This is not the home of configuration plugins in general: a local configuration plugin can
/// live in any directory below the plugins root. Only the IT department of an organization
/// deploys plugins here, each in a directory named after its configuration ID.
/// deploys plugins here, each deployment in a directory named after its configuration ID.<br/><br/>
/// A deployment is not limited to a configuration, even though the directory name says so. An
/// organization serves one archive per configuration ID and uses it for every kind of plugin:
/// assistants, languages, themes, and whatever else follows. Those plugins live in
/// subdirectories, each with its own plugin.lua and its own plugin ID, and only the
/// configuration plugin itself carries the configuration ID as its ID. Everything below such a
/// deployment belongs to the organization, whatever its type is and however deeply it is nested.
/// </remarks>
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
/// <summary>
/// The directory administrators use to try out a configuration before their organization deploys it.
/// The directory administrators use to try a deployment out before their organization rolls it out.
/// </summary>
/// <remarks>
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
/// later rollout, including the approval of assistant plugins. In exchange, the directory is
/// emptied on every start: a test configuration lives for one session only. It also never gets
/// the protection of a deployed configuration, so users can remove or replace it through the user
/// interface.
/// later rollout, including the approval of assistant plugins and the protection against changes
/// through the user interface. It takes every kind of plugin, exactly like a real deployment, so
/// the directory structure of the later archive can be reproduced one to one. In exchange, the
/// directory is emptied on every start: a test lives for one session only.<br/><br/>
/// A test therefore ends by restarting AI Studio, or by removing the files again. Whoever builds
/// enterprise plugins places them here by hand in the first place, so both ways are open to them
/// anyway, and neither weakens what the directory grants a plugin.
/// </remarks>
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string.Empty;
@@ -114,9 +123,10 @@ public static partial class PluginFactory
/// </summary>
/// <remarks>
/// Only the IT department of an organization deploys plugins there: the config server downloads
/// them into a directory named after their configuration ID. We decide by path on purpose. The
/// Lua field DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be
/// deployed by an organization.
/// each deployment into a directory named after its configuration ID, and a plugin of any type
/// may sit in a subdirectory of it. We decide by path on purpose. The Lua field
/// DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be deployed by an
/// organization, and one an organization did deploy could deny it.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
@@ -130,19 +140,61 @@ public static partial class PluginFactory
public static bool IsEnterpriseTestConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, pluginPath);
/// <summary>
/// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration
/// server or staged for a test.
/// Checks whether a plugin belongs to an organization, either deployed by a configuration server
/// or staged for a test.
/// </summary>
/// <remarks>
/// Use this wherever a configuration speaks for the organization, e.g. when it approves assistant
/// plugins or claims a setting against a local configuration plugin. Do not use it where a
/// deployed configuration is protected against the user, e.g. against deletion: an administrator
/// must be able to get rid of their own test configuration.
/// This is the criterion for everything an organization owns, and it holds for every plugin type:
/// a configuration speaking for the organization when it approves assistant plugins or claims a
/// setting, and the protection of a plugin against the user, e.g. against deletion or editing
/// through the user interface.<br/><br/>
/// A test deployment is protected just like a real one, so that a test shows what colleagues will
/// see later. Administrators end a test by restarting AI Studio or by removing the files they
/// placed, which is why they do not need the user interface to get rid of it.<br/><br/>
/// Plugins an organization rolls out past these directories, e.g. through an MDM solution, carry
/// no path to prove it. Those declare DEPLOYED_USING_CONFIG_SERVER instead, which is read into
/// the IsManagedByConfigServer property of a plugin's metadata. Check that property in addition
/// to this method wherever a plugin is protected against the user.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
public static bool IsOrganizationConfigurationPath(string? pluginPath) => IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseTestConfigurationPath(pluginPath);
/// <summary>
/// Determines which deployed configuration a plugin below the enterprise configuration directory
/// belongs to.
/// </summary>
/// <remarks>
/// A configuration server downloads each configuration into a directory named after its ID. That
/// archive may carry more than the configuration itself: organizations deploy assistant plugins
/// and other plugin types alongside it, each in its own subdirectory. We therefore look at the
/// topmost directory below the enterprise configuration directory instead of the directory the
/// plugin lives in, which for such a plugin is a nested one.
/// </remarks>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="configurationId">The ID of the configuration the plugin was deployed with.</param>
/// <returns>True when the plugin is nested in a directory named after a configuration ID.</returns>
public static bool TryGetDeployedConfigurationId(string? pluginPath, out Guid configurationId)
{
configurationId = Guid.Empty;
if (!IsEnterpriseConfigurationPath(pluginPath))
return false;
try
{
var root = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT);
var relativePath = Path.GetRelativePath(root, Path.GetFullPath(pluginPath!));
var deploymentDirectory = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0];
return Guid.TryParse(deploymentDirectory, out configurationId) && configurationId != Guid.Empty;
}
catch (Exception e)
{
LOG.LogWarning(e, $"Was not able to determine the deployed configuration ID for the plugin directory '{pluginPath}'.");
return false;
}
}
/// <summary>
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
/// wins when two configuration plugins claim the same plugin ID.