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
@@ -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);
}
}