Compare commits
4 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f8661e98 | ||
| 4058b88784 | |||
|
|
6b04a85f86 | ||
| cd8691280a |
@@ -27,21 +27,19 @@ only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
|
||||
## 2. Write-gating by role
|
||||
## 2. Write-gating by role — **DONE (PR 26)**
|
||||
|
||||
**Status**: `RoleBasedIdentity.Roles` populated on the session (PR 19) but
|
||||
`DriverNodeManager.OnWriteValue` doesn't consult it.
|
||||
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||
`SecurityClassification` → required role (`FreeAccess` → no role required,
|
||||
`Operate`/`SecuredWrite` → `WriteOperate`, `Tune` → `WriteTune`,
|
||||
`Configure`/`VerifiedWrite` → `WriteConfigure`, `ViewOnly` → deny regardless).
|
||||
`DriverNodeManager` caches the classification per variable during discovery and
|
||||
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
|
||||
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
|
||||
can't write a `Tune` attribute unless it also carries `WriteTune`.
|
||||
|
||||
CLAUDE.md defines the role set: `ReadOnly` / `WriteOperate` / `WriteTune` /
|
||||
`WriteConfigure` / `AlarmAck`. Each `DriverAttributeInfo.SecurityClassification`
|
||||
maps to a required role for writes.
|
||||
|
||||
**To do**:
|
||||
- Add a `RoleRequirements` table: `SecurityClassification` → required role.
|
||||
- `OnWriteValue` reads `context.UserIdentity` → cast to `RoleBasedIdentity`
|
||||
→ check role membership before calling `IWritable.WriteAsync`. Return
|
||||
`BadUserAccessDenied` on miss.
|
||||
- Unit test against a fake `ISystemContext` with varying role sets.
|
||||
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||
|
||||
## 3. Admin UI client-cert trust management
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
@@ -0,0 +1,172 @@
|
||||
@page "/fleet"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">Fleet status</h1>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Applied</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Failed</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Cluster</th>
|
||||
<th>Generation</th>
|
||||
<th>Status</th>
|
||||
<th>Last applied</th>
|
||||
<th>Last seen</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
</td>
|
||||
<td>@FormatAge(r.AppliedAt)</td>
|
||||
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
|
||||
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||
// the most recent published state without polling ahead of the broadcaster.
|
||||
private const int RefreshIntervalSeconds = 5;
|
||||
|
||||
private List<FleetNodeRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||
.OrderBy(r => r.ClusterId)
|
||||
.ThenBy(r => r.NodeId)
|
||||
.ToListAsync();
|
||||
_rows = rows;
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStale(FleetNodeRow r)
|
||||
{
|
||||
if (r.SeenAt is null) return true;
|
||||
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||
{
|
||||
"Failed" => "table-danger",
|
||||
_ when IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StatusBadge(string? status) => status switch
|
||||
{
|
||||
"Applied" => "bg-success",
|
||||
"Failed" => "bg-danger",
|
||||
"Applying" => "bg-info",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime? t)
|
||||
{
|
||||
if (t is null) return "—";
|
||||
var age = DateTime.UtcNow - t.Value;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
internal sealed record FleetNodeRow(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private FolderState? _driverRoot;
|
||||
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// PR 26: SecurityClassification per variable, populated during Variable() registration.
|
||||
// OnWriteValue looks up the classification here to gate the write by the session's roles.
|
||||
// Drivers never enforce authz themselves — the classification is discovery-time metadata
|
||||
// only (feedback_acl_at_server_layer.md).
|
||||
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Active building folder — set per Folder() call so Variable() lands under the right parent.
|
||||
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
|
||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_currentFolder.AddChild(v);
|
||||
AddPredefinedNode(SystemContext, v);
|
||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
|
||||
v.OnReadValue = OnReadValue;
|
||||
v.OnWriteValue = OnWriteValue;
|
||||
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var fullRef = node.NodeId.Identifier as string;
|
||||
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
||||
|
||||
// PR 26: server-layer write authorization. Look up the attribute's classification
|
||||
// (populated during Variable() in Discover) and check the session's roles against the
|
||||
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
|
||||
// never sees a request we'd have refused here.
|
||||
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
|
||||
{
|
||||
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
|
||||
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
|
||||
fullRef, classification, string.Join(",", roles));
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = _writable.WriteAsync(
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
||||
/// uses the stack's default.
|
||||
/// </summary>
|
||||
private sealed class RoleBasedIdentity : UserIdentity
|
||||
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
|
||||
{
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
public string? Display { get; }
|
||||
|
||||
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
|
||||
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
|
||||
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
|
||||
/// drive the authz policy under different role sets.
|
||||
/// </summary>
|
||||
public interface IRoleBearer
|
||||
{
|
||||
IReadOnlyList<string> Roles { get; }
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
|
||||
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
|
||||
/// whether a given session is allowed to write a given attribute by checking the session's
|
||||
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
|
||||
/// role for the attribute's classification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matches the table in <c>docs/Configuration.md</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
|
||||
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
|
||||
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
|
||||
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
|
||||
/// <item><c>ViewOnly</c>: no role grants write access.</item>
|
||||
/// </list>
|
||||
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
|
||||
/// </remarks>
|
||||
public static class WriteAuthzPolicy
|
||||
{
|
||||
public const string RoleWriteOperate = "WriteOperate";
|
||||
public const string RoleWriteTune = "WriteTune";
|
||||
public const string RoleWriteConfigure = "WriteConfigure";
|
||||
|
||||
/// <summary>
|
||||
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
|
||||
/// attribute with the given <paramref name="classification"/>. Returns true for
|
||||
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
|
||||
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
|
||||
/// the session to carry the mapped role — case-insensitive match.
|
||||
/// </summary>
|
||||
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
|
||||
{
|
||||
if (classification == SecurityClassification.FreeAccess) return true;
|
||||
if (classification == SecurityClassification.ViewOnly) return false;
|
||||
|
||||
var required = RequiredRole(classification);
|
||||
if (required is null) return false;
|
||||
|
||||
foreach (var r in userRoles)
|
||||
{
|
||||
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Required role for a classification, or null when no role grants access
|
||||
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
|
||||
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
|
||||
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
|
||||
/// null themselves).
|
||||
/// </summary>
|
||||
public static string? RequiredRole(SecurityClassification classification) => classification switch
|
||||
{
|
||||
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
|
||||
SecurityClassification.Operate => RoleWriteOperate,
|
||||
SecurityClassification.SecuredWrite => RoleWriteOperate,
|
||||
SecurityClassification.Tune => RoleWriteTune,
|
||||
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
|
||||
SecurityClassification.Configure => RoleWriteConfigure,
|
||||
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteAuthzPolicyTests
|
||||
{
|
||||
// --- FreeAccess and ViewOnly special-cases ---
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_even_for_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_for_arbitrary_roles()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewOnly_denies_write_even_with_every_role()
|
||||
{
|
||||
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Operate tier ---
|
||||
|
||||
[Fact]
|
||||
public void Operate_requires_WriteOperate_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_role_match_is_case_insensitive()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_wrong_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Tune tier ---
|
||||
|
||||
[Fact]
|
||||
public void Tune_requires_WriteTune_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tune_denies_WriteOperate_only_session()
|
||||
{
|
||||
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
|
||||
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
|
||||
// hierarchy the policy infers on its own.
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Configure tier ---
|
||||
|
||||
[Fact]
|
||||
public void Configure_requires_WriteConfigure_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Multi-role sessions ---
|
||||
|
||||
[Fact]
|
||||
public void Session_with_multiple_roles_is_allowed_when_any_matches()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_with_only_unrelated_roles_is_denied()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Mapping table ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
|
||||
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.FreeAccess)]
|
||||
[InlineData(SecurityClassification.ViewOnly)]
|
||||
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user