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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user