Apply technical-light design system to the gateway dashboard

Restyles the Blazor dashboard onto a portable token-based theme so it
reads like an instrument panel: warm-paper background, hairline-ruled
panels, IBM Plex type, monospace tabular numerics, and status carried by
colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css
as a thin token-driven view layer, and swaps the Bootstrap navbar and
status badges for the design-system app bar and chips.

Also includes pending API-key management, Galaxy hierarchy projection,
and constraint-enforcement work with their tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-16 22:30:25 -04:00
parent a4ed605f74
commit 96bea1d478
42 changed files with 3529 additions and 178 deletions
@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
@@ -2,55 +2,34 @@
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
<div class="container-fluid">
<a class="navbar-brand" href="">MXAccess Gateway</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="dashboardNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="workers">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="apikeys">API Keys</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
</ul>
<AuthorizeView>
<Authorized Context="authState">
<div class="d-flex align-items-center gap-2">
<span class="navbar-text">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</nav>
<main class="container-fluid dashboard-content">
<header class="app-bar">
<a class="brand" href=""><span class="mark">&#9646;</span> MXAccess Gateway</a>
<nav class="app-nav">
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="sessions">Sessions</NavLink>
<NavLink href="workers">Workers</NavLink>
<NavLink href="events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink>
<NavLink href="apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink>
</nav>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</header>
<main class="page">
@Body
</main>
</div>
@@ -0,0 +1,459 @@
@page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase
@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>
}
@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">
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
<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">
<div class="api-key-management-grid">
<div class="mb-3">
<label for="keyId" class="form-label">Key ID</label>
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
</div>
<div class="mb-3">
<label for="displayName" class="form-label">Display Name</label>
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
</div>
</div>
<fieldset class="mb-3">
<legend class="form-label">Scopes</legend>
<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>
</fieldset>
<div class="api-key-management-grid">
<div class="mb-3">
<label for="readSubtrees" class="form-label">Read subtrees</label>
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeSubtrees" class="form-label">Write subtrees</label>
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="readTagGlobs" class="form-label">Read tag globs</label>
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-3">
<label for="maxWriteClassification" class="form-label">Max write classification</label>
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
</div>
</div>
<div class="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>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
</div>
</EditForm>
</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>
@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="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></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>
@if (CanManageApiKeys)
{
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
<button type="button" class="btn btn-outline-secondary"
disabled="@IsBusy"
@onclick="() => RotateApiKeyAsync(key.KeyId)">
Rotate
</button>
@if (key.RevokedUtc is null)
{
<button type="button" class="btn btn-outline-danger"
disabled="@IsBusy"
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
Revoke
</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
];
private 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()
{
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 async Task RevokeApiKeyAsync(string keyId)
{
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
user,
keyId,
CancellationToken.None))
.ConfigureAwait(false);
}
private async Task RotateApiKeyAsync(string keyId)
{
await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
user,
keyId,
CancellationToken.None))
.ConfigureAwait(false);
}
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;
}
private void OpenCreateDialog()
{
IsCreateDialogOpen = true;
}
private void CloseCreateDialog()
{
if (!IsBusy)
{
IsCreateDialogOpen = false;
}
}
private 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 MxGateway.Server.Security.Authentication.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));
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);
}
}
private static string ConstraintText(MxGateway.Server.Security.Authentication.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);
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)}]");
}
}
private static IReadOnlyList<string> ParseList(string? value)
{
return (value ?? string.Empty)
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToArray();
}
private 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 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;
ReadAlarmOnly = false;
ReadHistorizedOnly = false;
}
}
}
@@ -1,4 +1,4 @@
<span class="badge @CssClass">@Text</span>
<span class="chip @CssClass">@Text</span>
@code {
[Parameter]
@@ -6,12 +6,11 @@
private string CssClass => Text switch
{
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Stale" => "text-bg-warning",
"Faulted" or "Unavailable" => "text-bg-danger",
"Unknown" => "text-bg-light text-dark border",
_ => "text-bg-light text-dark border"
"Ready" or "Healthy" or "Active" => "chip-ok",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
"Stale" or "Degraded" => "chip-warn",
"Faulted" or "Unavailable" => "chip-bad",
"Closed" or "Revoked" or "Unknown" => "chip-idle",
_ => "chip-idle"
};
}
@@ -0,0 +1,22 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
{
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return false;
}
string requiredGroup = options.Value.Ldap.RequiredGroup;
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value);
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
}
}
@@ -0,0 +1,9 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardApiKeyManagementRequest(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints);
@@ -0,0 +1,17 @@
namespace MxGateway.Server.Dashboard;
public sealed record DashboardApiKeyManagementResult(
bool Succeeded,
string Message,
string? ApiKey)
{
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
{
return new DashboardApiKeyManagementResult(true, message, apiKey);
}
public static DashboardApiKeyManagementResult Fail(string message)
{
return new DashboardApiKeyManagementResult(false, message, null);
}
}
@@ -0,0 +1,195 @@
using System.Security.Claims;
using Microsoft.Data.Sqlite;
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeySecretHasher hasher,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
public async Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateCreateRequest(request);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string keyId = request.KeyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
try
{
await adminStore.CreateAsync(
new ApiKeyCreateRequest(
KeyId: keyId,
KeyPrefix: $"mxgw_{keyId}",
SecretHash: hasher.HashSecret(secret),
DisplayName: request.DisplayName.Trim(),
Scopes: request.Scopes,
Constraints: request.Constraints,
CreatedUtc: DateTimeOffset.UtcNow),
cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
}
catch (ApiKeyPepperUnavailableException)
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
{
return DashboardApiKeyManagementResult.Fail("An API key with that id already exists.");
}
}
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateKeyId(keyId);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string normalizedKeyId = keyId.Trim();
bool revoked = await adminStore
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-revoke-key",
revoked ? "revoked" : "not-found-or-already-revoked",
cancellationToken)
.ConfigureAwait(false);
return revoked
? DashboardApiKeyManagementResult.Success("API key revoked.")
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
public async Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken)
{
if (!CanManage(user))
{
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
}
string? validation = ValidateKeyId(keyId);
if (validation is not null)
{
return DashboardApiKeyManagementResult.Fail(validation);
}
string normalizedKeyId = keyId.Trim();
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(normalizedKeyId, secret);
try
{
bool rotated = await adminStore
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-rotate-key",
rotated ? "rotated" : "not-found",
cancellationToken)
.ConfigureAwait(false);
return rotated
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
: DashboardApiKeyManagementResult.Fail("API key was not found.");
}
catch (ApiKeyPepperUnavailableException)
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
}
private async Task AppendAuditAsync(
string? keyId,
string eventType,
string? details,
CancellationToken cancellationToken)
{
await auditStore.AppendAsync(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
{
string? keyIdValidation = ValidateKeyId(request.KeyId);
if (keyIdValidation is not null)
{
return keyIdValidation;
}
if (string.IsNullOrWhiteSpace(request.DisplayName))
{
return "Display name is required.";
}
return null;
}
private static string? ValidateKeyId(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return "API key id is required.";
}
return keyId.Trim().All(character =>
char.IsAsciiLetterOrDigit(character)
|| character is '.' or '-')
? null
: "API key id may contain only letters, numbers, periods, and hyphens.";
}
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
}
@@ -0,0 +1,12 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardApiKeySummary(
string KeyId,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -0,0 +1,28 @@
using Microsoft.Data.SqlClient;
namespace MxGateway.Server.Dashboard;
public static class DashboardConnectionStringDisplay
{
public static string GalaxyRepositoryConnectionString(string connectionString)
{
try
{
SqlConnectionStringBuilder builder = new(connectionString);
SqlConnectionStringBuilder display = new()
{
DataSource = builder.DataSource,
InitialCatalog = builder.InitialCatalog,
IntegratedSecurity = builder.IntegratedSecurity,
Encrypt = builder.Encrypt,
TrustServerCertificate = builder.TrustServerCertificate,
};
return display.ConnectionString;
}
catch (ArgumentException)
{
return "[invalid connection string]";
}
}
}
@@ -169,11 +169,17 @@ public static class DashboardEndpointRouteBuilderExtensions
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
</head>
<body class="dashboard-body">
<main class="container py-5">
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
<header class="app-bar">
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header>
<main class="page">
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@@ -0,0 +1,23 @@
using System.Security.Claims;
namespace MxGateway.Server.Dashboard;
public interface IDashboardApiKeyManagementService
{
bool CanManage(ClaimsPrincipal user);
Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
}