feat(auth): ScadaBridge CentralUI pages onto IInboundApiKeyAdmin seam (re-arch C3; string keyId, method-scopes replace ApprovedApiKeyIds, token-once display, approved-keys<->scopes inversion)
This commit is contained in:
@@ -3,9 +3,12 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -44,14 +47,14 @@
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
var checkboxId = $"approved-key-{key.KeyId}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
checked="@_selectedKeyIds.Contains(key.KeyId)"
|
||||
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
@@ -195,9 +198,15 @@
|
||||
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin
|
||||
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
|
||||
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
|
||||
// per-key method-scopes. Keys are identified by an opaque string KeyId.
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
private List<InboundApiKeyInfo> _allKeys = new();
|
||||
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
|
||||
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
|
||||
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
@@ -209,7 +218,8 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
// All keys come from the seam (hash-free projection).
|
||||
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
@@ -225,7 +235,10 @@
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
// Seed approved keys from the seam: which keys' scopes contain this method.
|
||||
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
||||
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
||||
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -233,25 +246,12 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void ToggleKey(int keyId, bool isChecked)
|
||||
private void ToggleKey(string keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
@@ -263,15 +263,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
|
||||
// key scopes against it. The method entity stays in SQL Server; we leave the
|
||||
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
|
||||
string methodName;
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
methodName = _existing.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -279,17 +282,65 @@
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
ReturnDefinition = _returns?.Trim()
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
methodName = m.Name;
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
|
||||
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
|
||||
if (!await ReconcileKeyScopesAsync(methodName)) return;
|
||||
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes this method's name into / out of each affected key's scope set. Returns false
|
||||
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
|
||||
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
|
||||
/// key so we never clobber unrelated method-scopes.
|
||||
/// </summary>
|
||||
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
|
||||
{
|
||||
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
|
||||
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
|
||||
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
foreach (var keyId in affected)
|
||||
{
|
||||
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
|
||||
}
|
||||
|
||||
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
|
||||
|
||||
var plan = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
|
||||
|
||||
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
|
||||
if (plan.EmptyScopeKeyNames.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", plan.EmptyScopeKeyNames);
|
||||
_formError =
|
||||
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
|
||||
"Disable or delete the key instead, or grant it another method first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var update in plan.Updates)
|
||||
{
|
||||
await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
|
||||
}
|
||||
|
||||
// Selection is now the baseline (matters if save is retried without reload).
|
||||
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
Reference in New Issue
Block a user