Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
44d4448b37 Admin RoleGrants page — LDAP-group → Admin-role mapping CRUD. Closes the RoleGrantsTab slice of task #144 (Phase 6.2 Stream D follow-up); the remaining three sub-items (Probe-this-permission on AclsTab, SignalR invalidation on role/ACL changes, draft-diff ACL section) are split into new follow-up task #196 so each can ship independently. The permission-trie evaluator + ILdapGroupRoleMappingService already exist from Phase 6.2 Streams A + B — this PR adds the consuming UI + the DI registration that was missing. New /role-grants page at Components/Pages/RoleGrants.razor registered in MainLayout's sidebar next to Certificates. Lists every LdapGroupRoleMapping row with columns LDAP group / Role / Scope (Fleet-wide or Cluster:X) / Created / Notes / Revoke. Add-grant form takes LDAP group DN + AdminRole dropdown (ConfigViewer, ConfigEditor, FleetAdmin) + Fleet-wide checkbox + Cluster dropdown (disabled when Fleet-wide checked) + optional Notes. Service-layer invariants — IsSystemWide=true + ClusterId=null, or IsSystemWide=false + ClusterId populated — enforced in ValidateInvariants; UI catches InvalidLdapGroupRoleMappingException and displays the message in a red alert. ILdapGroupRoleMappingService was present in the Configuration project from Stream A but never registered in the Admin DI container — this PR adds the AddScoped registration so the injection can resolve. Control-plane/data-plane separation note rendered in an info banner at the top of the page per decision #150 (these grants do NOT govern OPC UA data-path authorization; NodeAcl rows are read directly by the permission-trie evaluator without consulting role mappings). Admin project builds 0 errors; Admin.Tests 72/72 passing. Task #196 created to track: (1) AclsTab Probe-this-permission form that takes (ldap group, node path, permission flag) and runs it through the permission trie, showing which row granted it + the actual resolved grant; (2) SignalR invalidation — push a RoleGrantsChanged event when rows are created/deleted so connected Admin sessions reload without polling, ditto NodeAclChanged on ACL writes; (3) DiffViewer ACL section — show NodeAcl + LdapGroupRoleMapping deltas between draft + published alongside equipment/uns diffs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:46:21 -04:00
572f8887e4 Merge pull request (#133) - IdentificationFields editor + edit mode 2026-04-19 21:43:18 -04:00
Joseph Doherty
2acea08ced Admin Equipment editor — IdentificationFields component + edit mode + three missing OPC 40010 fields. Closes the UI-editor slice of task #159 (Phase 6.4 Stream D remaining); the DriverNodeManager wire-in + ACL integration test are split into a new follow-up task #195 because they're blocked on a prerequisite that hasn't shipped — the DriverNodeManager does not currently materialize Equipment nodes at all (NodeScopeResolver has an explicit "A future resolver will..." TODO in its decomposition docstring). Shipping the IdentificationFolderBuilder call before the parent walker exists would wire a call that no code path hits, so the wire-in is deferred until the Equipment node walker lands first. New IdentificationFields.razor reusable component renders the 9-field decision #139 grid in a Bootstrap 3-column layout — Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction (InputNumber), AssetLocation, ManufacturerUri (placeholder https://…), DeviceManualUri (placeholder https://…). Takes a required Equipment parameter + 2-way binds every field; no state of its own. Three fields that were missing from the old inline form — AssetLocation, ManufacturerUri, DeviceManualUri — now present, matching IdentificationFolderBuilder.FieldNames exactly. EquipmentTab.razor refactored to consume the component in both create + edit flows. Each table row gains an Edit button next to Remove. StartEdit clones the row into _draft so Cancel doesn't mutate the displayed list row with in-flight edits; on Save, UpdateAsync persists through EquipmentService's existing update path which already handles all 9 Identification fields. SaveAsync branches on _editMode — create still derives EquipmentId from a fresh Uuid via DraftValidator per decision #125, edit keeps the original EquipmentId + EquipmentUuid (immutable once set). FormName renamed equipment-form (was new-equipment) to work for both flows. Admin project builds 0 errors; Admin.Tests 72/72 passing. No new tests shipped — this PR is strictly a Razor-component refactor + two new bound fields + an Edit branch; the existing EquipmentService tests cover both the create + update paths. Task #195 created to track the blocked server-side work: call IdentificationFolderBuilder.Build from DriverNodeManager once the Equipment walker exists, plus an integration test browsing Equipment/Identification as an unauthorized user asserting BadUserAccessDenied per the builder's cross-reference note in docs/v2/acl-design.md §Identification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:41:13 -04:00
49f6c9484e Merge pull request (#132) - Admin /hosts red-badge + Polly telemetry observer 2026-04-19 21:38:11 -04:00
5 changed files with 274 additions and 20 deletions

View File

@@ -10,6 +10,7 @@
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
</ul>
<div class="mt-5">

View File

@@ -36,7 +36,10 @@ else if (_equipment.Count > 0)
<td>@e.SAPID</td>
<td>@e.Manufacturer / @e.Model</td>
<td>@e.SerialNumber</td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
</td>
</tr>
}
</tbody>
@@ -47,8 +50,8 @@ else if (_equipment.Count > 0)
{
<div class="card mt-3">
<div class="card-body">
<h5>New equipment</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
<DataAnnotationsValidator/>
<div class="row g-3">
<div class="col-md-4">
@@ -78,24 +81,13 @@ else if (_equipment.Count > 0)
</div>
</div>
<h6 class="mt-4">OPC 40010 Identification</h6>
<div class="row g-3">
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
<div class="col-md-4">
<label class="form-label">Year of construction</label>
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
</div>
</div>
<IdentificationFields Equipment="_draft"/>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
</div>
</EditForm>
</div>
@@ -106,6 +98,7 @@ else if (_equipment.Count > 0)
[Parameter] public long GenerationId { get; set; }
private List<Equipment>? _equipment;
private bool _showForm;
private bool _editMode;
private Equipment _draft = NewBlankDraft();
private string? _error;
@@ -125,20 +118,68 @@ else if (_equipment.Count > 0)
private void StartAdd()
{
_draft = NewBlankDraft();
_editMode = false;
_error = null;
_showForm = true;
}
private void StartEdit(Equipment row)
{
// Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits.
_draft = new Equipment
{
EquipmentRowId = row.EquipmentRowId,
GenerationId = row.GenerationId,
EquipmentId = row.EquipmentId,
EquipmentUuid = row.EquipmentUuid,
DriverInstanceId = row.DriverInstanceId,
DeviceId = row.DeviceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
EquipmentClassRef = row.EquipmentClassRef,
Enabled = row.Enabled,
};
_editMode = true;
_error = null;
_showForm = true;
}
private void Cancel()
{
_showForm = false;
_editMode = false;
}
private async Task SaveAsync()
{
_error = null;
_draft.EquipmentUuid = Guid.NewGuid();
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
_draft.GenerationId = GenerationId;
try
{
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
if (_editMode)
{
await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None);
}
else
{
_draft.EquipmentUuid = Guid.NewGuid();
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
_draft.GenerationId = GenerationId;
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
}
_showForm = false;
_editMode = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }

View File

@@ -0,0 +1,49 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
create + edit forms so the same UI renders regardless of which flow opened it. *@
<h6 class="mt-4">OPC 40010 Identification</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Manufacturer</label>
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Model</label>
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Serial number</label>
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Hardware rev</label>
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Software rev</label>
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Year of construction</label>
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Asset location</label>
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Manufacturer URI</label>
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
</div>
<div class="col-md-4">
<label class="form-label">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
</div>
</div>
@code {
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
}

View File

@@ -0,0 +1,161 @@
@page "/role-grants"
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
@inject ILdapGroupRoleMappingService RoleSvc
@inject ClusterService ClusterSvc
<h1 class="mb-4">LDAP group → Admin role grants</h1>
<div class="alert alert-info small mb-4">
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
may hold different roles on different clusters.
</div>
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
only the bootstrap admin can publish drafts.</p>
}
else
{
<table class="table table-sm table-hover">
<thead>
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td><code>@r.LdapGroup</code></td>
<td><span class="badge bg-secondary">@r.Role</span></td>
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
<td class="small text-muted">@r.Notes</td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
</tr>
}
</tbody>
</table>
}
@if (_showForm)
{
<div class="card mt-3">
<div class="card-body">
<h5>New role grant</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">LDAP group (DN)</label>
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
</div>
<div class="col-md-3">
<label class="form-label">Role</label>
<select class="form-select" @bind="_role">
@foreach (var r in Enum.GetValues<AdminRole>())
{
<option value="@r">@r</option>
}
</select>
</div>
<div class="col-md-2 pt-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
<label class="form-check-label" for="systemWide">Fleet-wide</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide">
<option value="">-- select --</option>
@if (_clusters is not null)
{
@foreach (var c in _clusters)
{
<option value="@c.ClusterId">@c.ClusterId</option>
}
}
</select>
</div>
<div class="col-12">
<label class="form-label">Notes (optional)</label>
<input class="form-control" @bind="_notes"/>
</div>
</div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
</div>
}
@code {
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
private List<ServerCluster>? _clusters;
private bool _showForm;
private string _group = string.Empty;
private AdminRole _role = AdminRole.ConfigViewer;
private bool _isSystemWide;
private string _clusterId = string.Empty;
private string? _notes;
private string? _error;
protected override async Task OnInitializedAsync() => await ReloadAsync();
private async Task ReloadAsync()
{
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
}
private void StartAdd()
{
_group = string.Empty;
_role = AdminRole.ConfigViewer;
_isSystemWide = false;
_clusterId = string.Empty;
_notes = null;
_error = null;
_showForm = true;
}
private async Task SaveAsync()
{
_error = null;
try
{
var row = new LdapGroupRoleMapping
{
LdapGroup = _group.Trim(),
Role = _role,
IsSystemWide = _isSystemWide,
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
};
await RoleSvc.CreateAsync(row, CancellationToken.None);
_showForm = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
}
private async Task DeleteAsync(Guid id)
{
await RoleSvc.DeleteAsync(id, CancellationToken.None);
await ReloadAsync();
}
}

View File

@@ -48,6 +48,8 @@ builder.Services.AddScoped<ReservationService>();
builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<HostStatusService>();
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just