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:
Joseph Doherty
2026-06-02 04:36:50 -04:00
parent 8219b8ee18
commit 107e524914
8 changed files with 576 additions and 164 deletions
@@ -1,9 +1,11 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@page "/admin/api-keys/{KeyId}/edit"
@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
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@@ -46,15 +48,16 @@
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
else if (_saved && _newlyCreatedToken != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
<div class="d-flex align-items-center mt-2">
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
@@ -66,39 +69,37 @@
{
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
@* Name is fixed on edit — the seam has no rename. *@
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodNames.Contains(method.Name)"
@onchange="e => ToggleMethod(method.Name, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method. At least one is required.
</div>
}
</div>
@if (IsEditMode)
{
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodIds.Contains(method.Id)"
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method.
</div>
}
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
@@ -111,21 +112,26 @@
</div>
@code {
[Parameter] public int? Id { get; set; }
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
// Keys are identified by an opaque string KeyId; method access is a set of method
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
[Parameter] public string? KeyId { get; set; }
private bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey;
private InboundApiKeyInfo? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private string? _newlyCreatedToken;
private string? _newlyCreatedKeyId;
private bool _loading = true;
private bool _saved;
private List<ApiMethod> _allMethods = new();
private HashSet<int> _initialMethodIds = new();
private HashSet<int> _selectedMethodIds = new();
// Selection set is method NAMES (scopes), not method ids.
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
private ToastNotification _toast = default!;
@@ -133,22 +139,23 @@
{
try
{
if (Id.HasValue)
// Methods always come from SQL Server (methods stay on the repository).
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
if (!string.IsNullOrWhiteSpace(KeyId))
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
// No single-key getter on the seam — locate this key in the full list.
var all = await ApiKeyAdmin.ListAsync();
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
_errorMessage = $"API key '{KeyId}' not found.";
}
else
{
_formName = _editingKey.Name;
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_initialMethodIds = _allMethods
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
.Select(m => m.Id)
.ToHashSet();
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
}
}
}
@@ -162,40 +169,29 @@
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
// The seam/server reject empty scope sets; validate in the UI for a clear message.
if (_selectedMethodNames.Count == 0)
{
_formError = "Select at least one API method for this key.";
return;
}
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
var changedIds = _selectedMethodIds
.Except(_initialMethodIds)
.Concat(_initialMethodIds.Except(_selectedMethodIds))
.ToHashSet();
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
{
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
else ids.Remove(_editingKey.Id);
method.ApprovedApiKeyIds = ids.Count == 0
? null
: string.Join(",", ids.OrderBy(x => x));
await InboundApiRepository.UpdateApiMethodAsync(method);
}
await InboundApiRepository.SaveChangesAsync();
// Edit: name is fixed; only the method-scope set is mutable.
await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
_newlyCreatedKeyId = created.KeyId;
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
_saved = true;
}
}
@@ -207,28 +203,18 @@
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void ToggleMethod(int methodId, bool isChecked)
private void ToggleMethod(string methodName, bool isChecked)
{
if (isChecked) _selectedMethodIds.Add(methodId);
else _selectedMethodIds.Remove(methodId);
}
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();
if (isChecked) _selectedMethodNames.Add(methodName);
else _selectedMethodNames.Remove(methodName);
}
private async Task CopyKeyToClipboard()
{
if (_newlyCreatedKeyValue == null) return;
if (_newlyCreatedToken == null) return;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
@@ -236,12 +222,4 @@
_toast.ShowError("Copy failed.");
}
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}
@@ -1,9 +1,8 @@
@page "/admin/api-keys"
@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
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
@@ -44,29 +43,29 @@
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Key ID</th>
<th>Name</th>
<th>Key Hash</th>
<th>Methods</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var key in FilteredKeys)
{
<tr @key="key.Id">
<td>@key.Id</td>
<tr @key="key.KeyId">
<td><code>@TruncateKeyId(key.KeyId)</code></td>
<td>
@key.Name
@if (!key.IsEnabled)
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</td>
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
<td>@key.Methods.Count</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown"
@@ -75,7 +74,7 @@
<li>
<button class="dropdown-item"
@onclick="() => ToggleKey(key)">
@(key.IsEnabled ? "Disable" : "Enable")
@(key.Enabled ? "Disable" : "Enable")
</button>
</li>
<li><hr class="dropdown-divider" /></li>
@@ -98,14 +97,17 @@
</div>
@code {
private List<ApiKey> _keys = new();
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
private List<InboundApiKeyInfo> _keys = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = string.Empty;
private ToastNotification _toast = default!;
private IEnumerable<ApiKey> FilteredKeys =>
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
string.IsNullOrWhiteSpace(_search)
? _keys
: _keys.Where(k =>
@@ -122,7 +124,7 @@
_errorMessage = null;
try
{
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
}
catch (Exception ex)
{
@@ -131,20 +133,22 @@
_loading = false;
}
private static string MaskKeyValue(string keyValue)
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
private static string TruncateKeyId(string keyId)
{
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
if (string.IsNullOrEmpty(keyId)) return keyId;
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
}
private async Task ToggleKey(ApiKey key)
private async Task ToggleKey(InboundApiKeyInfo key)
{
try
{
key.IsEnabled = !key.IsEnabled;
await InboundApiRepository.UpdateApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
var newEnabled = !key.Enabled;
// The seam persists; there is no separate SaveChangesAsync.
await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
await LoadDataAsync();
}
catch (Exception ex)
{
@@ -152,7 +156,7 @@
}
}
private async Task DeleteKey(ApiKey key)
private async Task DeleteKey(InboundApiKeyInfo key)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete API Key",
@@ -162,8 +166,7 @@
try
{
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
await InboundApiRepository.SaveChangesAsync();
await ApiKeyAdmin.DeleteAsync(key.KeyId);
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
await LoadDataAsync();
}
@@ -1,9 +1,10 @@
@page "/"
@attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -108,7 +109,7 @@
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
_apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
}
catch
{
@@ -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;
@@ -0,0 +1,99 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Pure helper for the inbound-API-key re-arch (C3) on the API-method form. The approval
/// relationship moved from <c>ApiMethod.ApprovedApiKeyIds</c> (a CSV on the method) onto
/// per-key method-scopes managed through <c>IInboundApiKeyAdmin</c>. When an operator edits
/// the "Approved API Keys" list for one method, we must reconcile that method's NAME into (or
/// out of) each affected key's scope set — without touching keys whose membership did not change.
/// </summary>
/// <remarks>
/// Factored out of the Blazor component so the add/remove/empty-guard logic is unit-testable
/// independent of bUnit rendering.
/// </remarks>
public static class ApiMethodKeyScopeReconciler
{
/// <summary>
/// The recomputed scope set for one key that must be persisted via
/// <c>IInboundApiKeyAdmin.SetMethodsAsync</c>.
/// </summary>
/// <param name="KeyId">The key whose scopes changed.</param>
/// <param name="NewMethods">The full new method-scope set for that key (always non-empty —
/// the empty case is reported as a conflict instead).</param>
public sealed record KeyScopeUpdate(string KeyId, IReadOnlyList<string> NewMethods);
/// <summary>
/// Outcome of reconciling one method's approved-key selection against the keys' current scopes.
/// </summary>
/// <param name="Updates">Per-affected-key new scope sets to push. Empty when nothing changed.</param>
/// <param name="EmptyScopeKeyNames">Display names of keys for which revoking this method would
/// leave zero scopes. When this is non-empty the caller MUST NOT apply <see cref="Updates"/> —
/// it should surface a validation error and abort, because the server rejects empty scope sets.</param>
public sealed record ReconcileResult(
IReadOnlyList<KeyScopeUpdate> Updates,
IReadOnlyList<string> EmptyScopeKeyNames);
/// <summary>
/// Computes the scope edits needed so that exactly the keys in <paramref name="selectedKeyIds"/>
/// are scoped to <paramref name="methodName"/>.
/// </summary>
/// <param name="methodName">The method whose approval set is being edited. Must already exist
/// in the store (save the ApiMethod entity FIRST so its name resolves).</param>
/// <param name="selectedKeyIds">Keys the operator wants approved for this method (after edit).</param>
/// <param name="initialKeyIds">Keys that were approved for this method when the form loaded
/// (empty on create).</param>
/// <param name="currentMethodsByKey">Each affected key's CURRENT full scope set, keyed by KeyId.
/// Read fresh from the seam right before reconciling so concurrent edits do not get clobbered.</param>
/// <param name="keyNamesById">Display names by KeyId, for human-readable empty-scope messages.</param>
public static ReconcileResult Reconcile(
string methodName,
IReadOnlySet<string> selectedKeyIds,
IReadOnlySet<string> initialKeyIds,
IReadOnlyDictionary<string, IReadOnlyList<string>> currentMethodsByKey,
IReadOnlyDictionary<string, string> keyNamesById)
{
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
ArgumentNullException.ThrowIfNull(selectedKeyIds);
ArgumentNullException.ThrowIfNull(initialKeyIds);
ArgumentNullException.ThrowIfNull(currentMethodsByKey);
ArgumentNullException.ThrowIfNull(keyNamesById);
var added = selectedKeyIds.Except(initialKeyIds, StringComparer.Ordinal);
var removed = initialKeyIds.Except(selectedKeyIds, StringComparer.Ordinal);
var updates = new List<KeyScopeUpdate>();
var emptyScopeKeyNames = new List<string>();
// Approving: add this method's name to the key's current scope set (idempotent).
foreach (var keyId in added)
{
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
? m
: (IReadOnlyList<string>)Array.Empty<string>();
var next = new HashSet<string>(current, StringComparer.Ordinal) { methodName };
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
}
// Revoking: remove this method's name. If that empties the key, it is a conflict —
// the server rejects empty scope sets, so we report it and the caller must abort.
foreach (var keyId in removed)
{
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
? m
: (IReadOnlyList<string>)Array.Empty<string>();
var next = new HashSet<string>(current, StringComparer.Ordinal);
next.Remove(methodName);
if (next.Count == 0)
{
emptyScopeKeyNames.Add(
keyNamesById.TryGetValue(keyId, out var name) ? name : keyId);
continue;
}
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
}
return new ReconcileResult(updates, emptyScopeKeyNames);
}
}
@@ -3,30 +3,37 @@ using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using NSubstitute;
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.Security;
using ApiKeyForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKeyForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page. The chip
/// routes operators into the central Audit Log pre-filtered by Actor = ApiKey.Name
/// AND Channel = ApiInbound (no other channel uses the key name as actor, but
/// the explicit channel scope keeps deep links tight). Create mode suppresses
/// the link — there's no API key to drill into yet.
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page, re-wired for the
/// inbound-API-key re-arch (C3) onto the <see cref="IInboundApiKeyAdmin"/> seam. The chip
/// routes operators into the central Audit Log pre-filtered by Actor = key Name AND
/// Channel = ApiInbound (no other channel uses the key name as actor, but the explicit
/// channel scope keeps deep links tight). Create mode suppresses the link — there's no API
/// key to drill into yet. Also covers the new seam-driven list and one-time-token panel.
/// </summary>
public class ApiKeyFormAuditDrillinTests : BunitContext
{
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
private readonly IInboundApiRepository _repo = Substitute.For<IInboundApiRepository>();
public ApiKeyFormAuditDrillinTests()
{
Services.AddSingleton(_admin);
Services.AddSingleton(_repo);
// Methods still come from the SQL Server repository; default to none unless a test overrides.
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
var claims = new[]
{
new Claim("Username", "admin"),
@@ -38,16 +45,21 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
private static InboundApiKeyInfo Key(
string keyId, string name, bool enabled = true, params string[] methods) =>
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
[Fact]
public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel()
{
var key = ApiKey.FromHash("Orders-Integration", "k-hash");
key.Id = 11;
_repo.GetApiKeyByIdAsync(11, Arg.Any<CancellationToken>()).Returns(key);
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
const string keyId = "abc123def456";
var info = Key(keyId, "Orders-Integration", true, "PlaceOrder");
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[] { info }));
_admin.GetMethodsForKeyAsync(keyId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "PlaceOrder" }));
var cut = Render<ApiKeyForm>(p => p.Add(c => c.Id, 11));
var cut = Render<ApiKeyForm>(p => p.Add(c => c.KeyId, keyId));
cut.WaitForAssertion(() =>
{
@@ -62,6 +74,9 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
[Fact]
public void CreatePage_HasNoRecentAuditActivityLink()
{
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
var cut = Render<ApiKeyForm>();
cut.WaitForAssertion(() =>
@@ -69,4 +84,56 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
});
}
[Fact]
public async Task CreatePage_WithMethodSelected_ShowsOneTimeTokenAndKeyId()
{
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(
new List<ApiMethod> { new("PlaceOrder", "return null;") { Id = 1 } }));
_admin.CreateAsync("MES-Production", Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new InboundApiKeyCreated("new-key-id", "sbk_new-key-id_secret")));
var cut = Render<ApiKeyForm>();
// Fill name, check the only method, save.
cut.WaitForElement("input[type=text]").Change("MES-Production");
cut.Find("#method-access-1").Change(true);
await cut.Find("button.btn-success").ClickAsync(new());
cut.WaitForAssertion(() =>
{
var token = cut.Find("[data-test=\"created-token\"]");
Assert.Contains("sbk_new-key-id_secret", token.TextContent);
Assert.Contains("new-key-id", cut.Markup);
Assert.Contains("will not be shown again", cut.Markup);
});
await _admin.Received(1).CreateAsync(
"MES-Production",
Arg.Is<IReadOnlyCollection<string>>(m => m.Count == 1 && m.Contains("PlaceOrder")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreatePage_WithNoMethodSelected_ShowsValidationError_AndDoesNotCreate()
{
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(
new List<ApiMethod> { new("PlaceOrder", "return null;") { Id = 1 } }));
var cut = Render<ApiKeyForm>();
cut.WaitForElement("input[type=text]").Change("MES-Production");
await cut.Find("button.btn-success").ClickAsync(new());
cut.WaitForAssertion(() =>
Assert.Contains("at least one API method", cut.Markup));
await _admin.DidNotReceive().CreateAsync(
Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,95 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Security;
using ApiKeys = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKeys;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Inbound-API key re-arch (C3): the API Keys list page now reads keys from the
/// <see cref="IInboundApiKeyAdmin"/> seam (string KeyId + method-scopes) instead of the SQL
/// Server ApiKey entity. There is no retrievable hash, so the old masked Key-Hash column is gone.
/// </summary>
public class ApiKeysListPageTests : BunitContext
{
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
public ApiKeysListPageTests()
{
Services.AddSingleton(_admin);
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
var claims = new[]
{
new Claim("Username", "admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
private static InboundApiKeyInfo Key(
string keyId, string name, bool enabled, params string[] methods) =>
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
[Fact]
public void List_RendersKeysFromSeam_WithNoKeyHashColumn()
{
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[]
{
Key("aaaaaaaaaaaa1111", "Orders-Integration", true, "PlaceOrder", "GetStatus"),
Key("bbbbbbbbbbbb2222", "Disabled-Key", false, "Ping"),
}));
var cut = Render<ApiKeys>();
cut.WaitForAssertion(() =>
{
// Both key names render.
Assert.Contains("Orders-Integration", cut.Markup);
Assert.Contains("Disabled-Key", cut.Markup);
// The disabled key carries the Disabled badge.
Assert.Contains("Disabled", cut.Markup);
// No Key-Hash column header.
var headers = cut.FindAll("th").Select(h => h.TextContent.Trim()).ToList();
Assert.DoesNotContain(headers, h => h.Contains("Hash", StringComparison.OrdinalIgnoreCase));
Assert.Contains(headers, h => h == "Key ID");
Assert.Contains(headers, h => h == "Methods");
// KeyId renders truncated (first 12 chars + ellipsis), not the full value.
Assert.Contains("aaaaaaaaaaaa…", cut.Markup);
// The Methods column shows the per-key scope count (2 for the first key).
var cells = cut.FindAll("td").Select(c => c.TextContent.Trim()).ToList();
Assert.Contains("2", cells);
});
}
[Fact]
public async Task ToggleKey_CallsSetEnabledOnSeam()
{
_admin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[]
{
Key("aaaaaaaaaaaa1111", "Orders-Integration", true, "PlaceOrder"),
}));
var cut = Render<ApiKeys>();
// The dropdown's first item toggles enabled state (currently enabled -> Disable).
var disableButton = cut.WaitForElement("button.dropdown-item");
await disableButton.ClickAsync(new());
await _admin.Received(1).SetEnabledAsync("aaaaaaaaaaaa1111", false, Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,118 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Inbound-API key re-arch (C3): unit tests for <see cref="ApiMethodKeyScopeReconciler"/>, which
/// inverts the API-method form's "Approved API Keys" selection into per-key method-scope edits.
/// Covers approve, revoke (preserving other scopes), the empty-last-scope guard, and the no-op case.
/// </summary>
public sealed class ApiMethodKeyScopeReconcilerTests
{
private static IReadOnlyDictionary<string, IReadOnlyList<string>> Current(
params (string KeyId, string[] Methods)[] entries) =>
entries.ToDictionary(
e => e.KeyId,
e => (IReadOnlyList<string>)e.Methods.ToList(),
StringComparer.Ordinal);
private static IReadOnlyDictionary<string, string> Names(params (string KeyId, string Name)[] entries) =>
entries.ToDictionary(e => e.KeyId, e => e.Name, StringComparer.Ordinal);
[Fact]
public void Approve_AddsMethodToKey_PreservingExistingScopes()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string> { "k1" },
initialKeyIds: new HashSet<string>(),
currentMethodsByKey: Current(("k1", new[] { "GetStatus" })),
keyNamesById: Names(("k1", "Key One")));
Assert.Empty(result.EmptyScopeKeyNames);
var update = Assert.Single(result.Updates);
Assert.Equal("k1", update.KeyId);
Assert.Equal(new[] { "GetStatus", "PlaceOrder" }, update.NewMethods);
}
[Fact]
public void Approve_KeyWithNoExistingScopes_GetsJustThisMethod()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string> { "k1" },
initialKeyIds: new HashSet<string>(),
currentMethodsByKey: Current(("k1", Array.Empty<string>())),
keyNamesById: Names(("k1", "Key One")));
var update = Assert.Single(result.Updates);
Assert.Equal(new[] { "PlaceOrder" }, update.NewMethods);
}
[Fact]
public void Revoke_RemovesMethod_LeavingOtherScopesIntact()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string>(),
initialKeyIds: new HashSet<string> { "k1" },
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder", "GetStatus" })),
keyNamesById: Names(("k1", "Key One")));
Assert.Empty(result.EmptyScopeKeyNames);
var update = Assert.Single(result.Updates);
Assert.Equal("k1", update.KeyId);
Assert.Equal(new[] { "GetStatus" }, update.NewMethods);
}
[Fact]
public void Revoke_LastScope_ReportedAsEmptyConflict_AndNotInUpdates()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string>(),
initialKeyIds: new HashSet<string> { "k1" },
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder" })),
keyNamesById: Names(("k1", "Key One")));
Assert.Empty(result.Updates);
var emptyName = Assert.Single(result.EmptyScopeKeyNames);
Assert.Equal("Key One", emptyName);
}
[Fact]
public void Mixed_ApproveOneRevokeAnother_ProducesBothUpdates()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string> { "k2" }, // approve k2
initialKeyIds: new HashSet<string> { "k1" }, // revoke k1
currentMethodsByKey: Current(
("k1", new[] { "PlaceOrder", "GetStatus" }),
("k2", new[] { "Ping" })),
keyNamesById: Names(("k1", "Key One"), ("k2", "Key Two")));
Assert.Empty(result.EmptyScopeKeyNames);
Assert.Equal(2, result.Updates.Count);
var k1 = result.Updates.Single(u => u.KeyId == "k1");
Assert.Equal(new[] { "GetStatus" }, k1.NewMethods);
var k2 = result.Updates.Single(u => u.KeyId == "k2");
Assert.Equal(new[] { "Ping", "PlaceOrder" }, k2.NewMethods);
}
[Fact]
public void NoChange_ProducesNoUpdates()
{
var result = ApiMethodKeyScopeReconciler.Reconcile(
methodName: "PlaceOrder",
selectedKeyIds: new HashSet<string> { "k1" },
initialKeyIds: new HashSet<string> { "k1" },
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder" })),
keyNamesById: Names(("k1", "Key One")));
Assert.Empty(result.Updates);
Assert.Empty(result.EmptyScopeKeyNames);
}
}