fab600d3b0
The form split tags with the shared ParseList and attached the result verbatim, so "team-a, TEAM-A" persisted as two entries and the constraints column read dashboard_tags=[team-a, TEAM-A] — one grant reported as two on the page whose job is to show what a key was granted. Enforcement never saw it (a session holds its tags in a case-insensitive set), which is exactly why the display was the only place it could surface. De-duplicates ordinal-ignore-case at the attach point only, first spelling winning, matching ApiKeyAdminCommandLineParser.ParseDashboardTags. ParseList is untouched: the five glob lists are matched literally, so near-duplicates there are not necessarily the same rule and must survive verbatim — pinned by a test. The help text claimed to mirror the CLI flag; it now claims only the shared separators and the dedupe, since the form still drops an empty segment silently where the CLI hard-fails. A browser form has no exit code to fail with, so that difference stays, and Authorization.md now records it.
642 lines
29 KiB
Plaintext
642 lines
29 KiB
Plaintext
@page "/apikeys"
|
|
@inherits DashboardPageBase
|
|
@using ZB.MOM.WW.MxGateway.Server.Security.Authentication
|
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
@inject IDashboardApiKeyManagementService ApiKeyManagementService
|
|
|
|
<PageTitle>Dashboard API Keys</PageTitle>
|
|
|
|
@if (Snapshot is null)
|
|
{
|
|
<div class="empty-state">Loading API keys.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="dashboard-page-header">
|
|
<div>
|
|
<h1>API Keys</h1>
|
|
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
|
|
</div>
|
|
@if (CanManageApiKeys)
|
|
{
|
|
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
|
|
Create API Key
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
@if (CanManageApiKeys)
|
|
{
|
|
@if (!string.IsNullOrWhiteSpace(ResultMessage))
|
|
{
|
|
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
|
@ResultMessage
|
|
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
|
|
{
|
|
<div class="mt-2">
|
|
<code class="one-time-secret">@LastGeneratedApiKey</code>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<ConfirmDialog IsOpen="@(PendingAction is not null)"
|
|
Title="@(PendingAction?.Title ?? string.Empty)"
|
|
Message="@(PendingAction?.Message ?? string.Empty)"
|
|
ConfirmLabel="@(PendingAction?.ConfirmLabel ?? "Confirm")"
|
|
ConfirmButtonClass="@(PendingAction?.ConfirmButtonClass ?? "btn-primary")"
|
|
IsBusy="IsBusy"
|
|
OnConfirm="ConfirmPendingAsync"
|
|
OnCancel="CancelPending" />
|
|
|
|
@if (IsCreateDialogOpen)
|
|
{
|
|
<div class="modal-backdrop fade show"></div>
|
|
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
@* Master SiteForm pattern: card-wrapped body with stacked
|
|
subsections (h6 text-muted border-bottom). Modal chrome
|
|
is kept (close button), but the form-internal layout
|
|
now matches ScadaLink's admin forms — form-control-sm,
|
|
form-label small, mb-2/mb-3 row wrappers, Save/Cancel
|
|
inline at the bottom of the card-body (no modal-footer). *@
|
|
<div class="modal-header">
|
|
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
|
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title">Create API Key</h6>
|
|
<div class="mb-2">
|
|
<label for="keyId" class="form-label small">Key ID</label>
|
|
<input id="keyId" class="form-control form-control-sm" @bind="CreateModel.KeyId" @bind:event="oninput" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="displayName" class="form-label small">Display Name</label>
|
|
<input id="displayName" class="form-control form-control-sm" @bind="CreateModel.DisplayName" @bind:event="oninput" />
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Scopes</h6>
|
|
<div class="mb-3">
|
|
<div class="scope-grid">
|
|
@foreach (string scope in AvailableScopes)
|
|
{
|
|
<label class="form-check">
|
|
<input class="form-check-input" type="checkbox"
|
|
checked="@IsScopeSelected(scope)"
|
|
@onchange="eventArgs => SetScope(scope, eventArgs)" />
|
|
<span class="form-check-label">@scope</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Constraints</h6>
|
|
<div class="mb-2">
|
|
<label for="readSubtrees" class="form-label small">Read subtrees</label>
|
|
<textarea id="readSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="writeSubtrees" class="form-label small">Write subtrees</label>
|
|
<textarea id="writeSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="readTagGlobs" class="form-label small">Read tag globs</label>
|
|
<textarea id="readTagGlobs" class="form-control form-control-sm" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="writeTagGlobs" class="form-label small">Write tag globs</label>
|
|
<textarea id="writeTagGlobs" class="form-control form-control-sm" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="browseSubtrees" class="form-label small">Browse subtrees</label>
|
|
<textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="dashboardTags" class="form-label small">Dashboard tags</label>
|
|
<textarea id="dashboardTags" class="form-control form-control-sm" rows="2"
|
|
aria-describedby="dashboardTagsHelp"
|
|
@bind="CreateModel.DashboardTags" @bind:event="oninput"></textarea>
|
|
<div id="dashboardTagsHelp" class="form-text small">
|
|
Split on the same separators as the constraint fields above, and
|
|
de-duplicated case-insensitively — two spellings of a tag are one grant.
|
|
Matched case-insensitively against the viewer grants in
|
|
<code>Dashboard:GroupToTag</code>. Scopes dashboard event visibility only —
|
|
never what the key may read, write, or browse. Empty leaves the key's sessions
|
|
untagged, whose visibility follows <code>Dashboard:UntaggedSessionVisibility</code>.
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="maxWriteClassification" class="form-label small">Max write classification</label>
|
|
<input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
|
|
</div>
|
|
|
|
<h6 class="text-muted border-bottom pb-1">Filters</h6>
|
|
<div class="mb-3 d-flex flex-wrap gap-3">
|
|
<label class="form-check">
|
|
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
|
|
<span class="form-check-label">Read alarm only</span>
|
|
</label>
|
|
<label class="form-check">
|
|
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
|
|
<span class="form-check-label">Read historized only</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Create API key actions">
|
|
<button type="submit" class="btn btn-success" disabled="@IsBusy">Save</button>
|
|
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</EditForm>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
<section class="dashboard-section">
|
|
@if (Snapshot.ApiKeys.Count == 0)
|
|
{
|
|
<div class="empty-state">No API keys are available for display.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle dashboard-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Key</th>
|
|
<th scope="col">Status</th>
|
|
<th scope="col">Display Name</th>
|
|
<th scope="col">Scopes</th>
|
|
<th scope="col">Constraints</th>
|
|
<th scope="col">Created</th>
|
|
<th scope="col">Last Used</th>
|
|
<th scope="col">Expires</th>
|
|
@if (CanManageApiKeys)
|
|
{
|
|
<th scope="col">Actions</th>
|
|
}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
|
|
{
|
|
<tr>
|
|
<td><code>@key.KeyId</code></td>
|
|
<td><StatusBadge Text="@KeyStatus(key)" /></td>
|
|
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
|
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
|
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
|
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
|
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
|
<td>@(key.ExpiresUtc is null ? "Never" : DashboardDisplay.DateTime(key.ExpiresUtc))</td>
|
|
@if (CanManageApiKeys)
|
|
{
|
|
<td>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
|
@if (key.RevokedUtc is null)
|
|
{
|
|
@* Rotate clears revoked_utc, which would silently reactivate a
|
|
deliberately revoked key. Only offer it for active keys so a
|
|
revoked key is not un-revoked as a side effect of rotation. *@
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestRotate(key.KeyId)">
|
|
Rotate
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestRevoke(key.KeyId)">
|
|
Revoke
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="btn btn-outline-danger"
|
|
disabled="@IsBusy"
|
|
@onclick="() => RequestDelete(key.KeyId)">
|
|
Delete
|
|
</button>
|
|
}
|
|
</div>
|
|
</td>
|
|
}
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
private static readonly string[] AvailableScopes =
|
|
[
|
|
GatewayScopes.SessionOpen,
|
|
GatewayScopes.SessionClose,
|
|
GatewayScopes.InvokeRead,
|
|
GatewayScopes.InvokeWrite,
|
|
GatewayScopes.InvokeSecure,
|
|
GatewayScopes.EventsRead,
|
|
GatewayScopes.MetadataRead,
|
|
GatewayScopes.Admin
|
|
];
|
|
|
|
/// <summary>
|
|
/// Backing state for the create dialog. Internal rather than private so
|
|
/// <c>ApiKeysPageDashboardTagsTests</c> can drive the model-to-request mapping
|
|
/// directly — the assembly's established test seam (see <c>InternalsVisibleTo</c>
|
|
/// in <c>Properties/AssemblyInfo.cs</c>), because a create form behind a click is
|
|
/// unreachable from static rendering.
|
|
/// </summary>
|
|
internal ApiKeyCreateModel CreateModel { get; } = new();
|
|
|
|
private bool CanManageApiKeys { get; set; }
|
|
|
|
private bool IsBusy { get; set; }
|
|
|
|
private bool IsCreateDialogOpen { get; set; }
|
|
|
|
private string? ResultMessage { get; set; }
|
|
|
|
private bool LastOperationSucceeded { get; set; }
|
|
|
|
private string? LastGeneratedApiKey { get; set; }
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await base.OnInitializedAsync().ConfigureAwait(false);
|
|
|
|
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
|
.ConfigureAwait(false);
|
|
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
|
}
|
|
|
|
private async Task CreateApiKeyAsync()
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage))
|
|
{
|
|
SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid."));
|
|
return;
|
|
}
|
|
|
|
await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync(
|
|
user,
|
|
request,
|
|
CancellationToken.None))
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private PendingConfirm? PendingAction { get; set; }
|
|
|
|
private void RequestRotate(string keyId)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingAction = new PendingConfirm(
|
|
Title: "Rotate API key?",
|
|
Message: $"Rotate the secret for key {keyId}? Any client still using the previous secret will start failing immediately.",
|
|
ConfirmLabel: "Rotate",
|
|
ConfirmButtonClass: "btn-warning",
|
|
Action: user => ApiKeyManagementService.RotateAsync(user, keyId, CancellationToken.None));
|
|
}
|
|
|
|
private void RequestRevoke(string keyId)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingAction = new PendingConfirm(
|
|
Title: "Revoke API key?",
|
|
Message: $"Revoke key {keyId}? Clients using it will be rejected on the next request.",
|
|
ConfirmLabel: "Revoke",
|
|
ConfirmButtonClass: "btn-danger",
|
|
Action: user => ApiKeyManagementService.RevokeAsync(user, keyId, CancellationToken.None));
|
|
}
|
|
|
|
private void RequestDelete(string keyId)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingAction = new PendingConfirm(
|
|
Title: "Delete API key?",
|
|
Message: $"Permanently delete revoked key {keyId}? This removes the row from the auth database — only the audit log will retain the history.",
|
|
ConfirmLabel: "Delete",
|
|
ConfirmButtonClass: "btn-danger",
|
|
Action: user => ApiKeyManagementService.DeleteAsync(user, keyId, CancellationToken.None));
|
|
}
|
|
|
|
private void CancelPending()
|
|
{
|
|
if (!IsBusy)
|
|
{
|
|
PendingAction = null;
|
|
}
|
|
}
|
|
|
|
private async Task ConfirmPendingAsync()
|
|
{
|
|
if (IsBusy || PendingAction is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Server-047: align the pending-action lifecycle with SessionsPage / SessionDetailsPage —
|
|
// hold PendingAction while the awaited action runs so the shared ConfirmDialog can render
|
|
// its in-flight (IsBusy) state, then clear in finally regardless of outcome.
|
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action = PendingAction.Action;
|
|
try
|
|
{
|
|
await RunManagementActionAsync(action).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
PendingAction = null;
|
|
}
|
|
}
|
|
|
|
private sealed record PendingConfirm(
|
|
string Title,
|
|
string Message,
|
|
string ConfirmLabel,
|
|
string ConfirmButtonClass,
|
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> Action);
|
|
|
|
private async Task RunManagementActionAsync(
|
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
try
|
|
{
|
|
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
|
.ConfigureAwait(false);
|
|
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
|
DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false);
|
|
SetResult(result);
|
|
if (result.Succeeded && result.ApiKey is not null)
|
|
{
|
|
CreateModel.Reset();
|
|
IsCreateDialogOpen = false;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
private void SetResult(DashboardApiKeyManagementResult result)
|
|
{
|
|
LastOperationSucceeded = result.Succeeded;
|
|
ResultMessage = result.Message;
|
|
LastGeneratedApiKey = result.ApiKey;
|
|
}
|
|
|
|
/// <summary>Opens the create dialog. Internal so a render test can reach the form's markup.</summary>
|
|
internal void OpenCreateDialog()
|
|
{
|
|
IsCreateDialogOpen = true;
|
|
}
|
|
|
|
private void CloseCreateDialog()
|
|
{
|
|
if (!IsBusy)
|
|
{
|
|
IsCreateDialogOpen = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="CreateModel"/> onto a create request, or reports why it cannot.</summary>
|
|
/// <param name="request">The built request when this returns true.</param>
|
|
/// <param name="validationMessage">The reason the model is unusable when this returns false.</param>
|
|
/// <returns>True when the model produced a request.</returns>
|
|
internal bool TryBuildCreateRequest(
|
|
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
|
|
out string? validationMessage)
|
|
{
|
|
request = null;
|
|
validationMessage = null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
|
&& !int.TryParse(
|
|
CreateModel.MaxWriteClassification,
|
|
System.Globalization.NumberStyles.Integer,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out int _))
|
|
{
|
|
validationMessage = "Max write classification must be an integer.";
|
|
return false;
|
|
}
|
|
|
|
int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
|
? null
|
|
: int.Parse(
|
|
CreateModel.MaxWriteClassification,
|
|
System.Globalization.NumberStyles.Integer,
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
request = new DashboardApiKeyManagementRequest(
|
|
KeyId: CreateModel.KeyId,
|
|
DisplayName: CreateModel.DisplayName,
|
|
Scopes: CreateModel.SelectedScopes,
|
|
Constraints: new ApiKeyConstraints(
|
|
ReadSubtrees: ParseList(CreateModel.ReadSubtrees),
|
|
WriteSubtrees: ParseList(CreateModel.WriteSubtrees),
|
|
ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs),
|
|
WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs),
|
|
MaxWriteClassification: maxWriteClassification,
|
|
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
|
|
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
|
|
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)
|
|
{
|
|
// Init-only rather than positional (it was bolted onto the record after the
|
|
// eight-member constructor shipped), so it is attached here instead.
|
|
DashboardTags = ParseDashboardTags(CreateModel.DashboardTags),
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool IsScopeSelected(string scope)
|
|
{
|
|
return CreateModel.SelectedScopes.Contains(scope);
|
|
}
|
|
|
|
private void SetScope(string scope, ChangeEventArgs eventArgs)
|
|
{
|
|
bool selected = eventArgs.Value is bool value && value;
|
|
if (selected)
|
|
{
|
|
CreateModel.SelectedScopes.Add(scope);
|
|
}
|
|
else
|
|
{
|
|
CreateModel.SelectedScopes.Remove(scope);
|
|
}
|
|
}
|
|
|
|
// Window before an expiry within which a key is flagged as "Expiring" (warn) rather than "Active".
|
|
private static readonly TimeSpan ExpiringSoonWindow = TimeSpan.FromDays(7);
|
|
|
|
// Status vocabulary understood by StatusBadge: Revoked wins over expiry; a past expiry is Expired
|
|
// (bad), an expiry inside ExpiringSoonWindow is Expiring (warn), otherwise Active (SEC-10).
|
|
private static string KeyStatus(DashboardApiKeySummary key)
|
|
{
|
|
if (key.RevokedUtc is not null)
|
|
{
|
|
return "Revoked";
|
|
}
|
|
|
|
if (key.ExpiresUtc is { } expiresAt)
|
|
{
|
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
|
if (expiresAt <= now)
|
|
{
|
|
return "Expired";
|
|
}
|
|
|
|
if (expiresAt - now <= ExpiringSoonWindow)
|
|
{
|
|
return "Expiring";
|
|
}
|
|
}
|
|
|
|
return "Active";
|
|
}
|
|
|
|
private static string ConstraintText(ApiKeyConstraints constraints)
|
|
{
|
|
if (constraints.IsEmpty)
|
|
{
|
|
return "unconstrained";
|
|
}
|
|
|
|
List<string> parts = [];
|
|
AddList(parts, "read_subtrees", constraints.ReadSubtrees);
|
|
AddList(parts, "write_subtrees", constraints.WriteSubtrees);
|
|
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
|
|
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
|
|
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
|
|
|
|
// Listed like the rest even though it restricts no data path: IsEmpty counts it, so a key
|
|
// whose only policy is a dashboard tag is not "unconstrained", and omitting it here left
|
|
// that key's cell empty — rendered as "-", the same cell a key with no policy at all gets.
|
|
AddList(parts, "dashboard_tags", constraints.DashboardTags);
|
|
if (constraints.MaxWriteClassification is { } max)
|
|
{
|
|
parts.Add($"max_write_classification={max}");
|
|
}
|
|
|
|
if (constraints.ReadAlarmOnly)
|
|
{
|
|
parts.Add("read_alarm_only");
|
|
}
|
|
|
|
if (constraints.ReadHistorizedOnly)
|
|
{
|
|
parts.Add("read_historized_only");
|
|
}
|
|
|
|
return string.Join("; ", parts);
|
|
}
|
|
|
|
private static void AddList(List<string> parts, string name, IReadOnlyList<string> values)
|
|
{
|
|
if (values.Count > 0)
|
|
{
|
|
parts.Add($"{name}=[{string.Join(", ", values)}]");
|
|
}
|
|
}
|
|
|
|
// Dashboard tags alone are de-duplicated ordinal-ignore-case, which is how the enforcement site
|
|
// compares them — two spellings are one grant, and ApiKeyAdminCommandLineParser.ParseDashboardTags
|
|
// collapses them the same way, first spelling winning. Persisting both would not change who can
|
|
// see what (GatewaySession holds its tags in a case-insensitive set) but the constraints column
|
|
// would report one grant twice, and a security grant that reads as two is the wrong kind of
|
|
// wrong on an audit surface. The other five list fields keep ParseList's verbatim behaviour:
|
|
// their globs are matched literally, so near-duplicates there are not necessarily the same rule.
|
|
private static IReadOnlyList<string> ParseDashboardTags(string? value)
|
|
{
|
|
return [.. ParseList(value).Distinct(StringComparer.OrdinalIgnoreCase)];
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseList(string? value)
|
|
{
|
|
return (value ?? string.Empty)
|
|
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
|
.ToArray();
|
|
}
|
|
|
|
internal sealed class ApiKeyCreateModel
|
|
{
|
|
public string KeyId { get; set; } = string.Empty;
|
|
|
|
public string DisplayName { get; set; } = string.Empty;
|
|
|
|
public HashSet<string> SelectedScopes { get; } = new(StringComparer.Ordinal);
|
|
|
|
public string ReadSubtrees { get; set; } = string.Empty;
|
|
|
|
public string WriteSubtrees { get; set; } = string.Empty;
|
|
|
|
public string ReadTagGlobs { get; set; } = string.Empty;
|
|
|
|
public string WriteTagGlobs { get; set; } = string.Empty;
|
|
|
|
public string BrowseSubtrees { get; set; } = string.Empty;
|
|
|
|
public string MaxWriteClassification { get; set; } = string.Empty;
|
|
|
|
public string DashboardTags { get; set; } = string.Empty;
|
|
|
|
public bool ReadAlarmOnly { get; set; }
|
|
|
|
public bool ReadHistorizedOnly { get; set; }
|
|
|
|
public void Reset()
|
|
{
|
|
KeyId = string.Empty;
|
|
DisplayName = string.Empty;
|
|
SelectedScopes.Clear();
|
|
ReadSubtrees = string.Empty;
|
|
WriteSubtrees = string.Empty;
|
|
ReadTagGlobs = string.Empty;
|
|
WriteTagGlobs = string.Empty;
|
|
BrowseSubtrees = string.Empty;
|
|
MaxWriteClassification = string.Empty;
|
|
DashboardTags = string.Empty;
|
|
ReadAlarmOnly = false;
|
|
ReadHistorizedOnly = false;
|
|
}
|
|
}
|
|
}
|