Improved ERI server export flow

This commit is contained in:
Thorsten Sommer 2026-05-17 21:12:25 +02:00
parent d2b92784f5
commit 1c4f10ffdd
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
9 changed files with 136 additions and 193 deletions

View File

@ -1,52 +0,0 @@
@using AIStudio.Tools.ERIClient.DataModel
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudText Typo="Typo.body1" Class="mb-3">
@string.Format(T("Export the ERI v1 data source '{0}' as Lua code for a configuration plugin."), this.DataSource.Name)
</MudText>
@if (this.NeedsSecret())
{
@if (!this.HasConfiguredSecret)
{
<MudAlert Severity="Severity.Warning" Class="mb-3">
@T("No secret is configured for this ERI data source. The export will contain a placeholder that you need to replace manually.")
</MudAlert>
}
else if (!this.CanEncryptSecret)
{
<MudAlert Severity="Severity.Warning" Class="mb-3">
@T("No enterprise encryption secret is configured. The export will contain a placeholder that you need to replace manually.")
</MudAlert>
}
<MudTextSwitch @bind-Value="@this.includeSecret"
Disabled="@(!this.CanIncludeSecret)"
Label="@this.GetIncludeSecretLabel()"
LabelOn="@this.GetIncludeSecretLabelOn()"
LabelOff="@this.GetIncludeSecretLabelOff()" />
}
@if (this.DataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD)
{
<MudSelect @bind-Value="@this.usernamePasswordMode" Text="@this.GetUsernamePasswordModeText()" Label="@T("Username and password mode")" Class="mt-3 mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start">
@foreach (var mode in this.availableUsernamePasswordModes)
{
<MudSelectItem Value="@mode">
@this.GetUsernamePasswordModeText(mode)
</MudSelectItem>
}
</MudSelect>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Export" Variant="Variant.Filled" Color="Color.Primary">
@T("Export")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -1,83 +0,0 @@
using AIStudio.Components;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ERIClient.DataModel;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class DataSourceERIV1ExportDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public DataSourceERI_V1 DataSource { get; set; }
[Parameter]
public bool HasConfiguredSecret { get; set; }
[Parameter]
public bool CanEncryptSecret { get; set; }
private readonly DataSourceERIUsernamePasswordMode[] availableUsernamePasswordModes =
[
DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD,
DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD
];
private bool includeSecret;
private DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD;
private bool CanIncludeSecret => this.HasConfiguredSecret && this.CanEncryptSecret;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
this.includeSecret = this.CanIncludeSecret;
await base.OnInitializedAsync();
}
#endregion
private bool NeedsSecret() => this.DataSource.AuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD;
private string GetIncludeSecretLabel() => this.DataSource.AuthMethod switch
{
AuthMethod.TOKEN => T("Include the configured access token in the export?"),
AuthMethod.USERNAME_PASSWORD => T("Include the configured password in the export?"),
_ => T("Include the configured secret in the export?"),
};
private string GetIncludeSecretLabelOn() => this.DataSource.AuthMethod switch
{
AuthMethod.TOKEN => T("Yes, export the encrypted access token"),
AuthMethod.USERNAME_PASSWORD => T("Yes, export the encrypted password"),
_ => T("Yes, export the encrypted secret"),
};
private string GetIncludeSecretLabelOff() => this.DataSource.AuthMethod switch
{
AuthMethod.TOKEN => T("No, use a token placeholder"),
AuthMethod.USERNAME_PASSWORD => T("No, use a password placeholder"),
_ => T("No, use a placeholder"),
};
private string GetUsernamePasswordModeText() => this.GetUsernamePasswordModeText(this.usernamePasswordMode);
private string GetUsernamePasswordModeText(DataSourceERIUsernamePasswordMode mode) => mode switch
{
DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD => T("Read each user's username from the operating system and share one password"),
DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD => T("Use the same username and password for all users"),
_ => T("User-managed username and password"),
};
private void Cancel() => this.MudDialog.Cancel();
private void Export() => this.MudDialog.Close(DialogResult.Ok(new DataSourceERIV1ExportDialogResult(this.includeSecret, this.usernamePasswordMode)));
}

View File

@ -1,5 +0,0 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Dialogs;
public readonly record struct DataSourceERIV1ExportDialogResult(bool IncludeSecret, DataSourceERIUsernamePasswordMode UsernamePasswordMode);

View File

@ -0,0 +1,26 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudText Typo="Typo.body1" Class="mb-3">
@string.Format(T("How should AI Studio export the username and password configuration for the ERI v1 data source '{0}'?"), this.DataSource.Name)
</MudText>
<MudSelect @bind-Value="@this.usernamePasswordMode" Text="@this.GetUsernamePasswordModeText()" Label="@T("Username and password mode")" Class="mt-3 mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start">
@foreach (var mode in this.availableUsernamePasswordModes)
{
<MudSelectItem Value="@mode">
@this.GetUsernamePasswordModeText(mode)
</MudSelectItem>
}
</MudSelect>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Export" Variant="Variant.Filled" Color="Color.Primary">
@T("Export")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,37 @@
using AIStudio.Components;
using AIStudio.Settings.DataModel;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class DataSourceERIV1UsernamePasswordExportDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public DataSourceERI_V1 DataSource { get; set; }
private readonly DataSourceERIUsernamePasswordMode[] availableUsernamePasswordModes =
[
DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD,
DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD
];
private DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD;
private string GetUsernamePasswordModeText() => this.GetUsernamePasswordModeText(this.usernamePasswordMode);
private string GetUsernamePasswordModeText(DataSourceERIUsernamePasswordMode mode) => mode switch
{
DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD => T("Read each user's username from the operating system and share one password"),
DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD => T("Use the same username and password for all users"),
_ => T("User-managed username and password"),
};
private void Cancel() => this.MudDialog.Cancel();
private void Export() => this.MudDialog.Close(DialogResult.Ok(new DataSourceERIV1UsernamePasswordExportDialogResult(this.usernamePasswordMode)));
}

View File

@ -0,0 +1,5 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Dialogs;
public readonly record struct DataSourceERIV1UsernamePasswordExportDialogResult(DataSourceERIUsernamePasswordMode UsernamePasswordMode);

View File

@ -121,35 +121,66 @@ public partial class SettingsDialogDataSources : SettingsDialogBase
}
var secretResponse = await this.RustService.GetSecret(eriDataSource, SecretStoreType.DATA_SOURCE, isTrying: true);
var canEncryptSecret = PluginFactory.EnterpriseEncryption?.IsAvailable == true;
var dialogParameters = new DialogParameters<DataSourceERIV1ExportDialog>
if (!secretResponse.Success)
{
{ x => x.DataSource, eriDataSource },
{ x => x.HasConfiguredSecret, secretResponse.Success },
{ x => x.CanEncryptSecret, canEncryptSecret },
await this.DialogService.ShowMessageBox(
T("Export ERI Data Source"),
string.Format(T("Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}"), secretResponse.Issue),
T("Close"));
return;
}
var encryption = PluginFactory.EnterpriseEncryption;
if (encryption?.IsAvailable != true)
{
await this.DialogService.ShowMessageBox(
T("Export ERI Data Source"),
T("Cannot export this ERI data source because no enterprise encryption secret is configured."),
T("Close"));
return;
}
var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED;
if (eriDataSource.AuthMethod is AuthMethod.TOKEN)
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, T("This ERI data source has an access token configured. Do you want to include the encrypted access token in the export? Note: The recipient will need the same encryption secret to use the access token.") },
};
var dialogReference = await this.DialogService.ShowAsync<DataSourceERIV1ExportDialog>(T("Export ERI Data Source"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Export Access Token?"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DataSourceERIV1ExportDialogResult exportResult)
if (dialogResult is null || dialogResult.Canceled)
return;
}
else if (eriDataSource.AuthMethod is AuthMethod.USERNAME_PASSWORD)
{
var dialogParameters = new DialogParameters<DataSourceERIV1UsernamePasswordExportDialog>
{
{ x => x.DataSource, eriDataSource },
};
var dialogReference = await this.DialogService.ShowAsync<DataSourceERIV1UsernamePasswordExportDialog>(T("Export ERI Data Source"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DataSourceERIV1UsernamePasswordExportDialogResult exportResult)
return;
string? encryptedSecret = null;
if (exportResult.IncludeSecret && secretResponse.Success)
{
usernamePasswordMode = exportResult.UsernamePasswordMode;
}
var decryptedSecret = await secretResponse.Secret.Decrypt(Program.ENCRYPTION);
var encryption = PluginFactory.EnterpriseEncryption;
if (encryption?.TryEncrypt(decryptedSecret, out var encrypted) == true)
encryptedSecret = encrypted;
else
this.Snackbar.Add(T("Cannot export the encrypted secret. A placeholder will be used instead."), Severity.Warning);
if (!encryption.TryEncrypt(decryptedSecret, out var encryptedSecret))
{
await this.DialogService.ShowMessageBox(
T("Export ERI Data Source"),
T("Cannot export this ERI data source because the authentication secret could not be encrypted."),
T("Close"));
return;
}
var luaCode = eriDataSource.ExportAsConfigurationSection(
encryptedSecret,
exportResult.UsernamePasswordMode,
needsSecret && string.IsNullOrWhiteSpace(encryptedSecret));
usernamePasswordMode);
if (string.IsNullOrWhiteSpace(luaCode))
return;

View File

@ -277,9 +277,8 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
/// </summary>
/// <param name="encryptedSecret">Optional encrypted token or password to include in the export.</param>
/// <param name="usernamePasswordMode">The organization-managed username/password mode to export.</param>
/// <param name="useSecretPlaceholder">Whether to include a placeholder for the encrypted secret.</param>
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection(string? encryptedSecret = null, DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED, bool useSecretPlaceholder = false)
public string ExportAsConfigurationSection(string? encryptedSecret = null, DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED)
{
var secretLine = string.Empty;
var usernamePasswordModeLine = string.Empty;
@ -288,7 +287,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
switch (this.AuthMethod)
{
case AuthMethod.TOKEN:
secretLine = CreateSecretLine("Token", encryptedSecret, "ENC:v1:<base64-encoded encrypted token>", useSecretPlaceholder);
secretLine = CreateSecretLine("Token", encryptedSecret);
break;
case AuthMethod.USERNAME_PASSWORD:
@ -307,7 +306,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
""";
}
secretLine = CreateSecretLine("Password", encryptedSecret, "ENC:v1:<base64-encoded encrypted password>", useSecretPlaceholder);
secretLine = CreateSecretLine("Password", encryptedSecret);
break;
}
@ -375,19 +374,13 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
return true;
}
private static string CreateSecretLine(string fieldName, string? encryptedSecret, string placeholder, bool useSecretPlaceholder)
private static string CreateSecretLine(string fieldName, string? encryptedSecret)
{
var secret = !string.IsNullOrWhiteSpace(encryptedSecret)
? encryptedSecret
: useSecretPlaceholder
? placeholder
: string.Empty;
if (string.IsNullOrWhiteSpace(secret))
if (string.IsNullOrWhiteSpace(encryptedSecret))
return string.Empty;
return $"""
["{fieldName}"] = "{LuaTools.EscapeLuaString(secret)}",
["{fieldName}"] = "{LuaTools.EscapeLuaString(encryptedSecret)}",
""";
}

View File

@ -18,25 +18,17 @@ public sealed partial class RustService
public async Task<RequestedSecret> GetSecret(ISecretId secretId, SecretStoreType storeType, bool isTrying = false)
{
var secretKey = SecretKey(secretId, storeType);
var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue."));
}
var secret = await this.GetSecretByKey(secretKey, isTrying || storeType is SecretStoreType.DATA_SOURCE);
if (secret.Success || storeType is not SecretStoreType.DATA_SOURCE)
return secret;
var secret = await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
if (!secret.Success && storeType is SecretStoreType.DATA_SOURCE)
{
var legacySecret = await this.GetLegacySecret(secretId, isTrying: true);
var legacySecretKey = LegacySecretKey(secretId);
var legacySecret = await this.GetSecretByKey(legacySecretKey, isTrying: true);
if (legacySecret.Success)
{
this.logger!.LogDebug($"Successfully retrieved the legacy data source secret for '{LegacySecretKey(secretId)}'.");
this.logger!.LogDebug($"Successfully retrieved the legacy data source secret for '{legacySecretKey}'.");
return legacySecret;
}
}
if (!secret.Success && !isTrying)
this.logger!.LogError($"Failed to get the secret data for '{secretKey}': '{secret.Issue}'");
@ -92,9 +84,8 @@ public sealed partial class RustService
return deleteResult with { WasEntryFound = deleteResult.WasEntryFound || legacyDeleteResult.WasEntryFound };
}
private async Task<RequestedSecret> GetLegacySecret(ISecretId secretId, bool isTrying)
private async Task<RequestedSecret> GetSecretByKey(string secretKey, bool isTrying)
{
var secretKey = LegacySecretKey(secretId);
var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)