fix(admin): resolve Medium code-review finding (Admin-007)

NewCluster.razor and ClusterDetail.razor now resolve ClaimTypes.Name /
NameIdentifier from the cascaded AuthenticationState instead of hardcoding
"admin-ui" as the createdBy audit field. The operator principal is now
attributed correctly on every cluster-create and draft-create write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 07:27:40 -04:00
parent a9cede8ed4
commit 08f000069c
3 changed files with 26 additions and 6 deletions

View File

@@ -123,13 +123,13 @@
| Severity | Medium |
| Category | Design-document adherence |
| Location | `Components/Pages/Clusters/NewCluster.razor:91,95-96` |
| Status | Open |
| Status | Resolved |
**Description:** `NewCluster.CreateAsync` hardcodes `CreatedBy = "admin-ui"` (both on the `ServerCluster` row and the draft generation) instead of the signed-in operator principal name. `admin-ui.md` section "Audit" requires "the operator principal" be recorded on every write. The audit trail therefore cannot attribute cluster creation to a person. The same literal would apply to any anonymous creation that Admin-001/002 currently permit.
**Recommendation:** Pass the authenticated user identity (`ClaimTypes.Name` / `NameIdentifier` from the cascaded `AuthenticationState`) as `createdBy`. Apply the same pattern to every other Admin write path that records a `CreatedBy`/`PublishedBy`/`ReleasedBy` field.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `NewCluster.razor` and `ClusterDetail.razor` (the two pages that call `ClusterService.CreateAsync` / `GenerationService.CreateDraftAsync` with a hardcoded literal) now resolve `ClaimTypes.Name` / `ClaimTypes.NameIdentifier` from the cascaded `AuthenticationState` and pass the operator principal name as `createdBy`; the fallback is `"unknown"` (defensive, should never occur on an `[Authorize]`-gated page).
### Admin-008

View File

@@ -1,5 +1,6 @@
@page "/clusters/{ClusterId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using System.Security.Claims
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
@@ -200,7 +201,12 @@ else
_busy = true;
try
{
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
// Admin-007: record the authenticated operator's name, not a static literal.
var user = AuthState is not null ? (await AuthState).User : null;
var operatorName = user?.FindFirstValue(ClaimTypes.Name)
?? user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? "unknown";
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: operatorName, CancellationToken.None);
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
}
finally { _busy = false; }

View File

@@ -4,6 +4,8 @@
and its CreateAsync write path exploitable by any caller. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "CanPublish")]
@using System.ComponentModel.DataAnnotations
@using System.Security.Claims
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -73,6 +75,10 @@
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
}
// Admin-007: record the authenticated operator's identity on every write path, not
// the static literal "admin-ui" which produced an unattributable audit trail.
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private Input _input = new();
private bool _submitting;
private string? _error;
@@ -84,6 +90,14 @@
try
{
// Resolve the signed-in principal name. The page is [Authorize(Policy="CanPublish")]
// so AuthState will always be available with an authenticated user here; fall back to
// "unknown" only as a defensive last resort (should never happen in practice).
var user = AuthState is not null ? (await AuthState).User : null;
var operatorName = user?.FindFirstValue(ClaimTypes.Name)
?? user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? "unknown";
var cluster = new ServerCluster
{
ClusterId = _input.ClusterId,
@@ -93,11 +107,11 @@
RedundancyMode = _input.RedundancyMode,
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
Enabled = true,
CreatedBy = "admin-ui",
CreatedBy = operatorName,
};
await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None);
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None);
await ClusterSvc.CreateAsync(cluster, createdBy: operatorName, CancellationToken.None);
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: operatorName, CancellationToken.None);
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
}