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

Add @ReleasedBy parameter to sp_ReleaseExternalIdReservation via a new EF
migration so the operator principal (not the shared SQL account) is recorded
in ExternalIdReservation.ReleasedBy and ConfigAuditLog.Principal.
ReservationService.ReleaseAsync gains a releasedBy parameter; Reservations.razor
resolves the signed-in user from AuthenticationState and passes it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 07:29:54 -04:00
parent 71f91aa57c
commit 328ab1e614
4 changed files with 152 additions and 6 deletions

View File

@@ -1,5 +1,7 @@
@page "/reservations"
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -86,6 +88,10 @@
}
@code {
// Admin-008: capture the signed-in operator so the release is attributed correctly in the
// ExternalIdReservation.ReleasedBy column and the ConfigAuditLog.Principal column.
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private List<ExternalIdReservation>? _active;
private List<ExternalIdReservation>? _released;
private ExternalIdReservation? _releasing;
@@ -111,10 +117,20 @@
private async Task ReleaseAsync()
{
if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; }
// Resolve the operator principal. The page is [Authorize(Policy="CanPublish")] so
// AuthState will be available with an authenticated user; 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";
_busy = true;
try
{
await ReservationSvc.ReleaseAsync(_releasing.Kind.ToString(), _releasing.Value, _reason, CancellationToken.None);
await ReservationSvc.ReleaseAsync(
_releasing.Kind.ToString(), _releasing.Value, _reason, operatorName, CancellationToken.None);
_releasing = null;
await ReloadAsync();
}

View File

@@ -25,14 +25,24 @@ public sealed class ReservationService(OtOpcUaConfigDbContext db)
.Take(100)
.ToListAsync(ct);
public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
/// <summary>
/// Releases an active reservation, recording <paramref name="releasedBy"/> (the signed-in
/// Admin-UI operator) in <c>ExternalIdReservation.ReleasedBy</c> and the
/// <c>ConfigAuditLog.Principal</c> column.
///
/// Both <paramref name="reason"/> and <paramref name="releasedBy"/> are required audit
/// fields — the stored proc validates them and raises an error if either is null/empty.
/// </summary>
public async Task ReleaseAsync(string kind, string value, string reason, string releasedBy, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(reason))
throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
if (string.IsNullOrWhiteSpace(releasedBy))
throw new ArgumentException("ReleasedBy is required (audit invariant)", nameof(releasedBy));
await db.Database.ExecuteSqlRawAsync(
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
[kind, value, reason],
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}, @ReleasedBy = {3}",
[kind, value, reason, releasedBy],
ct);
}
}