From 107e524914546cad0db4e14eeb30e457b4001f74 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 04:36:50 -0400 Subject: [PATCH] feat(auth): ScadaBridge CentralUI pages onto IInboundApiKeyAdmin seam (re-arch C3; string keyId, method-scopes replace ApprovedApiKeyIds, token-once display, approved-keys<->scopes inversion) --- .../Components/Pages/Admin/ApiKeyForm.razor | 176 ++++++++---------- .../Components/Pages/Admin/ApiKeys.razor | 53 +++--- .../Components/Pages/Dashboard.razor | 5 +- .../Pages/Design/ApiMethodForm.razor | 103 +++++++--- .../Services/ApiMethodKeyScopeReconciler.cs | 99 ++++++++++ .../Admin/ApiKeyFormAuditDrillinTests.cs | 91 +++++++-- .../Admin/ApiKeysListPageTests.cs | 95 ++++++++++ .../ApiMethodKeyScopeReconcilerTests.cs | 118 ++++++++++++ 8 files changed, 576 insertions(+), 164 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ApiMethodKeyScopeReconciler.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ApiMethodKeyScopeReconcilerTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor index f847dca2..a0287807 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor @@ -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 @@ { } - else if (_saved && _newlyCreatedKeyValue != null) + else if (_saved && _newlyCreatedToken != null) {
New API Key Created -
- @_newlyCreatedKeyValue +
Key ID: @_newlyCreatedKeyId
+
+ @_newlyCreatedToken
- Save this key now. It will not be shown again in full. + Save this token now — it will not be shown again.
Back to API Keys } @@ -66,39 +69,37 @@ {
- + @* Name is fixed on edit — the seam has no rename. *@ + +
+
+ + @if (_allMethods.Count == 0) + { +
+ No API methods configured. + Create one to grant access. +
+ } + else + { +
+ @foreach (var method in _allMethods.OrderBy(m => m.Name)) + { + var checkboxId = $"method-access-{method.Id}"; +
+ + +
+ } +
+
+ Callers using this key can invoke any checked method. At least one is required. +
+ }
- @if (IsEditMode) - { -
- - @if (_allMethods.Count == 0) - { -
- No API methods configured. - Create one to grant access. -
- } - else - { -
- @foreach (var method in _allMethods.OrderBy(m => m.Name)) - { - var checkboxId = $"method-access-{method.Id}"; -
- - -
- } -
-
- Callers using this key can invoke any checked method. -
- } -
- } @if (_formError != null) {
@_formError
@@ -111,21 +112,26 @@
@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 _allMethods = new(); - private HashSet _initialMethodIds = new(); - private HashSet _selectedMethodIds = new(); + // Selection set is method NAMES (scopes), not method ids. + private HashSet _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(_initialMethodIds); + var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId); + _selectedMethodNames = new HashSet(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 ParseApprovedKeyIds(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return new HashSet(); - 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]; - } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor index d4b3c357..b06700de 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -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 @@ - + - + @foreach (var key in FilteredKeys) { - - + + - +
IDKey ID NameKey HashMethods Actions
@key.Id
@TruncateKeyId(key.KeyId) @key.Name - @if (!key.IsEnabled) + @if (!key.Enabled) { Disabled } @MaskKeyValue(key.KeyHash)@key.Methods.Count
+ @onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit @code { - private List _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 _keys = new(); private bool _loading = true; private string? _errorMessage; private string _search = string.Empty; private ToastNotification _toast = default!; - private IEnumerable FilteredKeys => + private IEnumerable 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(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Dashboard.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Dashboard.razor index d719a7e3..6c41bd33 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Dashboard.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Dashboard.razor @@ -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
@@ -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 { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor index eacc2d24..1d3f0d9f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -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 @@
@foreach (var key in _allKeys) { - var checkboxId = $"approved-key-{key.Id}"; + var checkboxId = $"approved-key-{key.KeyId}";
+ checked="@_selectedKeyIds.Contains(key.KeyId)" + @onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />