08f000069c
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>
125 lines
5.1 KiB
Plaintext
125 lines
5.1 KiB
Plaintext
@page "/clusters/new"
|
|
@* Cluster creation is a FleetAdmin operation per admin-ui.md "Add a new cluster" —
|
|
CanPublish gates it (Admin-002). Without this attribute the page was reachable
|
|
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
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
|
@rendermode RenderMode.InteractiveServer
|
|
@inject ClusterService ClusterSvc
|
|
@inject GenerationService GenerationSvc
|
|
@inject NavigationManager Nav
|
|
|
|
<h1 class="page-title mb-4">New cluster</h1>
|
|
|
|
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
|
<DataAnnotationsValidator/>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
|
|
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
|
|
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
|
<ValidationMessage For="() => _input.ClusterId"/>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Display name <span class="text-danger">*</span></label>
|
|
<InputText @bind-Value="_input.Name" class="form-control"/>
|
|
<ValidationMessage For="() => _input.Name"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Enterprise</label>
|
|
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Site</label>
|
|
<InputText @bind-Value="_input.Site" class="form-control"/>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Redundancy</label>
|
|
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
|
|
<option value="@RedundancyMode.None">None (single node)</option>
|
|
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
|
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
|
</InputSelect>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(_error))
|
|
{
|
|
<section class="panel notice mt-3">@_error</section>
|
|
}
|
|
|
|
<div class="mt-4">
|
|
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
|
|
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
|
|
</div>
|
|
</EditForm>
|
|
|
|
@code {
|
|
private sealed class Input
|
|
{
|
|
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
|
public string ClusterId { get; set; } = string.Empty;
|
|
|
|
[Required, StringLength(128)]
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
|
[StringLength(32)] public string Site { get; set; } = "dev";
|
|
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;
|
|
|
|
private async Task CreateAsync()
|
|
{
|
|
_submitting = true;
|
|
_error = null;
|
|
|
|
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,
|
|
Name = _input.Name,
|
|
Enterprise = _input.Enterprise,
|
|
Site = _input.Site,
|
|
RedundancyMode = _input.RedundancyMode,
|
|
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
|
Enabled = true,
|
|
CreatedBy = operatorName,
|
|
};
|
|
|
|
await ClusterSvc.CreateAsync(cluster, createdBy: operatorName, CancellationToken.None);
|
|
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: operatorName, CancellationToken.None);
|
|
|
|
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_error = ex.Message;
|
|
}
|
|
finally { _submitting = false; }
|
|
}
|
|
}
|