feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)

Adds a dashboard event-visibility tag to ApiKeyConstraints, riding in the existing
constraints JSON blob so no auth-store schema migration is needed (design
docs/plans/2026-07-10-dashboard-session-acl-tst15.md sections 3/3.1, open call
settled per its own recommendation). The tag is visibility-only: no read, write,
browse, or subscribe path consults it, and HasRead/HasWriteConstraints ignore it.

GatewaySession gains an immutable, ordinal-ignore-case Tags set stamped at
construction from the owning API key, forwarded by MxAccessGatewayService.OpenSession
from the resolved ApiKeyIdentity — never from the wire request, so a client cannot
label its own session with another tenant's tag. ISessionManager gains a tag-carrying
OpenSessionAsync overload whose default implementation forwards to the tagless one, so
an implementation that does not model tags opens an untagged (least visible) session.

apikey create-key gains --dashboard-tags team-a,team-b (repeatable, trimmed,
de-duplicated; an empty segment is rejected rather than dropped) and list-keys prints
the tags column. No enforcement yet — the EventsHub ACL that consumes the tag is a
later change.
This commit is contained in:
Joseph Doherty
2026-08-17 03:58:14 -04:00
parent 9130994736
commit a212e145ac
14 changed files with 477 additions and 10 deletions
@@ -32,11 +32,17 @@ public sealed class MxAccessGatewayService(
try
{
requestValidator.ValidateOpenSession(request);
// The session's owner id and its dashboard-visibility tags both come from the resolved
// API key identity, never from the request: the key is the tenant principal, so a
// client cannot label its own session with another tenant's tag (SEC-25).
ApiKeyIdentity? owner = identityAccessor.Current;
GatewaySession session = await sessionManager
.OpenSessionAsync(
SessionOpenRequest.FromContract(request),
ResolveClientIdentity(),
identityAccessor.Current?.KeyId,
owner?.KeyId,
owner?.EffectiveConstraints.DashboardTags,
context.CancellationToken)
.ConfigureAwait(false);
@@ -143,8 +143,13 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
string expiry = key.ExpiresUtc is { } expires
? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture)
: "-";
// Dashboard tags are operator-facing labels, not key material, so they are safe to
// print alongside the scopes; "-" keeps the column aligned for an untagged key.
string dashboardTags = key.Constraints.DashboardTags.Count > 0
? string.Join(',', key.Constraints.DashboardTags)
: "-";
await output.WriteLineAsync(
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}")
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}\t{dashboardTags}")
.ConfigureAwait(false);
}
}
@@ -233,7 +233,45 @@ public static class ApiKeyAdminCommandLineParser
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
BrowseSubtrees: GetOptions(options, "browse-subtree"),
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
ReadHistorizedOnly: HasFlag(options, "read-historized-only"))
{
DashboardTags = ParseDashboardTags(options),
};
}
// --dashboard-tags takes a comma-separated list ("team-a,team-b"); repeating the flag unions
// its values. Segments are trimmed and de-duplicated ordinal-ignore-case, matching how the
// enforcement site compares them. An empty segment is rejected rather than dropped: a stray
// comma otherwise silently persists a grant the operator did not mean to write.
private static IReadOnlyList<string> ParseDashboardTags(Dictionary<string, List<string?>> options)
{
if (!options.TryGetValue("dashboard-tags", out List<string?>? values))
{
return Array.Empty<string>();
}
List<string> tags = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string? raw in values)
{
foreach (string segment in (raw ?? string.Empty).Split(','))
{
string tag = segment.Trim();
if (tag.Length == 0)
{
throw new FormatException(
"--dashboard-tags must be a comma-separated list of non-empty tags.");
}
if (seen.Add(tag))
{
tags.Add(tag);
}
}
}
return tags.Count == 0 ? Array.Empty<string>() : tags;
}
// Parses the optional --expires value into an absolute UTC expiry. Accepts a relative
@@ -22,6 +22,11 @@ public static class ApiKeyConstraintSerializer
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized constraints, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null/whitespace.</returns>
/// <remarks>
/// Members absent from the JSON take their default: rows persisted before
/// <see cref="ApiKeyConstraints.DashboardTags"/> existed carry no <c>dashboard_tags</c>
/// member and deserialize to an untagged key, unchanged in every other respect.
/// </remarks>
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -10,6 +10,38 @@ public sealed record ApiKeyConstraints(
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
/// <summary>
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
/// </summary>
/// <remarks>
/// <para>
/// This is <em>dashboard event-visibility only</em>. It is <strong>never</strong> a
/// data-access constraint: no read, write, browse, or subscribe path consults it, and
/// adding a tag neither widens nor narrows what the key may read or write. Sessions
/// opened by the key inherit these tags (<c>GatewaySession.Tags</c>), and a dashboard
/// Viewer may observe a session's mirrored event metadata only when their granted tags
/// intersect the session's. It rides in the same serialized constraints blob purely to
/// avoid an auth-store schema migration — see
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c> §3.1.
/// </para>
/// <para>
/// Tag values are stored exactly as supplied; comparisons are ordinal-ignore-case at the
/// enforcement site, so <c>Team-A</c> and <c>team-a</c> name the same tag. An empty list
/// means untagged.
/// </para>
/// </remarks>
public IReadOnlyList<string> DashboardTags
{
get => _dashboardTags;
// Defensive copy: the tag set is a security-relevant grant, so the record must not alias a
// caller-owned list that could be mutated after construction. A null or empty value (an old
// persisted row has no dashboard_tags member at all) normalizes to untagged.
init => _dashboardTags = value is { Count: > 0 } ? [.. value] : Array.Empty<string>();
}
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
@@ -22,6 +54,11 @@ public sealed record ApiKeyConstraints(
ReadHistorizedOnly: false);
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
/// <remarks>
/// <see cref="DashboardTags"/> counts here even though it restricts nothing: an empty
/// instance is not persisted at all (<c>ApiKeyConstraintSerializer.Serialize</c> returns
/// null), so a key whose only per-key policy is a dashboard tag must still round-trip.
/// </remarks>
public bool IsEmpty =>
ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0
@@ -30,7 +67,8 @@ public sealed record ApiKeyConstraints(
&& MaxWriteClassification is null
&& BrowseSubtrees.Count == 0
&& !ReadAlarmOnly
&& !ReadHistorizedOnly;
&& !ReadHistorizedOnly
&& DashboardTags.Count == 0;
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
public bool HasReadConstraints =>
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
public sealed class GatewaySession
{
// Shared untagged sentinel: most sessions carry no dashboard tags. Frozen so the exposed set
// cannot be mutated by a cast — the tag set is a visibility grant, not a scratch collection.
private static readonly IReadOnlySet<string> EmptyTags = FrozenSet<string>.Empty;
private readonly object _syncRoot = new();
private readonly SemaphoreSlim _closeLock = new(1, 1);
private readonly SessionEventStreaming _eventStreaming;
@@ -149,6 +154,12 @@ public sealed class GatewaySession
/// <see cref="MarkFaulted"/> using <paramref name="eventStreaming"/>'s clock so the timer
/// is unit-testable.
/// </param>
/// <param name="ownerDashboardTags">
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into
/// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged.
/// The tags come from the owner key, never from the client's wire request, so a client
/// cannot label its own session with another tenant's tag.
/// </param>
public GatewaySession(
string sessionId,
string backendName,
@@ -167,7 +178,8 @@ public sealed class GatewaySession
TimeSpan detachGrace = default,
TimeSpan workerReadyWaitTimeout = default,
ArrayAddressNormalizer? addressNormalizer = null,
TimeSpan faultedGrace = default)
TimeSpan faultedGrace = default,
IReadOnlyList<string>? ownerDashboardTags = null)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -195,6 +207,9 @@ public sealed class GatewaySession
Nonce = nonce;
ClientIdentity = clientIdentity;
OwnerKeyId = ownerKeyId;
Tags = ownerDashboardTags is { Count: > 0 }
? ownerDashboardTags.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
: EmptyTags;
ClientSessionName = clientSessionName;
ClientCorrelationId = clientCorrelationId;
CommandTimeout = commandTimeout;
@@ -241,6 +256,19 @@ public sealed class GatewaySession
/// </summary>
public string? OwnerKeyId { get; }
/// <summary>
/// Gets the dashboard event-visibility tags this session inherited from its owning API key
/// (SEC-25). An empty set means untagged.
/// </summary>
/// <remarks>
/// Immutable for the session's life — assigned once at construction from the owner key's
/// <c>ApiKeyConstraints.DashboardTags</c> — so a dashboard subscription decided at join time
/// never has to be re-evaluated. The set compares ordinal-ignore-case. These tags gate
/// nothing on the gRPC data path; they exist only so the dashboard can scope which sessions'
/// mirrored event metadata a Viewer may observe.
/// </remarks>
public IReadOnlySet<string> Tags { get; }
/// <summary>
/// Gets the client-supplied session name.
/// </summary>
@@ -17,6 +17,33 @@ public interface ISessionManager
string? ownerKeyId,
CancellationToken cancellationToken);
/// <summary>
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
/// tags onto it (SEC-25).
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param>
/// <param name="ownerKeyId">API key identifier of the caller creating the session.</param>
/// <param name="ownerDashboardTags">
/// The owner key's <c>ApiKeyConstraints.DashboardTags</c>. Null or empty opens an untagged
/// session. Never sourced from the client's wire request — see
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c> §3.1.
/// </param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The newly opened session.</returns>
/// <remarks>
/// The default implementation forwards to the tagless overload, so an implementation that
/// does not model tags (unit-test fakes) opens an <em>untagged</em> session. That is the
/// fail-closed direction: untagged sessions are the least dashboard-visible ones.
/// </remarks>
Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
=> OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken);
/// <summary>Attempts to retrieve a session by ID.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session, if found.</param>
@@ -87,11 +87,20 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken)
=> OpenSessionAsync(request, clientIdentity, ownerKeyId, ownerDashboardTags: null, cancellationToken);
/// <inheritdoc />
public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
@@ -101,7 +110,7 @@ public sealed class SessionManager : ISessionManager
bool sessionOpenedRecorded = false;
try
{
session = CreateSession(request, clientIdentity, ownerKeyId);
session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags);
if (!_registry.TryAdd(session))
{
throw new SessionManagerException(
@@ -494,7 +503,8 @@ public sealed class SessionManager : ISessionManager
private GatewaySession CreateSession(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId)
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags)
{
string sessionUid = Guid.NewGuid().ToString("N");
string sessionId = $"session-{sessionUid}";
@@ -541,7 +551,8 @@ public sealed class SessionManager : ISessionManager
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.DetachGraceSeconds)),
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
_addressNormalizer,
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)));
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)),
ownerDashboardTags);
}
private static string CreateClientCorrelationId(
@@ -50,6 +50,45 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
}
/// <summary>
/// Verifies OpenSession forwards the calling key's dashboard-visibility tags, so the
/// session's tags are derived from the owning API key rather than the wire request (SEC-25).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSession_WithTaggedKey_ForwardsOwnerDashboardTags()
{
GatewayRequestIdentityAccessor identityAccessor = new();
FakeSessionManager sessionManager = new();
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
ApiKeyIdentity identity = CreateIdentity() with
{
Constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] },
};
using IDisposable identityScope = identityAccessor.Push(identity);
await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext());
Assert.Equal(["team-a"], sessionManager.LastOwnerDashboardTags);
}
/// <summary>
/// Verifies an unauthenticated OpenSession (no resolved key identity) opens an untagged
/// session — the fail-closed state for dashboard event visibility.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSession_WithoutIdentity_ForwardsNoDashboardTags()
{
FakeSessionManager sessionManager = new();
MxAccessGatewayService service = CreateService(sessionManager, new GatewayRequestIdentityAccessor());
await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext());
Assert.Null(sessionManager.LastOwnerDashboardTags);
Assert.Null(sessionManager.LastOwnerKeyId);
}
/// <summary>
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
/// service's own <c>ResolveSession</c> lookup. No <c>InvokeException</c> is
@@ -517,6 +556,9 @@ public sealed class MxAccessGatewayServiceTests
/// <summary>The last owner key id passed to OpenSessionAsync.</summary>
public string? LastOwnerKeyId { get; private set; }
/// <summary>The last owner dashboard tags passed to OpenSessionAsync.</summary>
public IReadOnlyList<string>? LastOwnerDashboardTags { get; private set; }
/// <summary>The last session ID the event stream service was asked to stream.</summary>
public string? LastReadEventsSessionId { get; private set; }
@@ -564,6 +606,19 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
{
LastOwnerDashboardTags = ownerDashboardTags;
return OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken);
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
@@ -109,6 +109,55 @@ public sealed class SessionManagerTests
Assert.Null(session.OwnerKeyId);
}
/// <summary>
/// Verifies a session inherits the owning API key's dashboard-visibility tags (SEC-25),
/// compared ordinal-ignore-case so a differently cased grant still matches.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithOwnerDashboardTags_CopiesTagsOntoSession()
{
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(),
clientIdentity: "MyKey Display",
ownerKeyId: "key-abc123",
ownerDashboardTags: ["team-a", "team-b"],
CancellationToken.None);
Assert.Equal(["team-a", "team-b"], session.Tags.OrderBy(tag => tag, StringComparer.Ordinal));
Assert.Contains("TEAM-A", session.Tags);
}
/// <summary>
/// Verifies a session opened by a key with no dashboard tags is untagged, which is the
/// fail-closed state for dashboard event visibility.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithoutOwnerDashboardTags_LeavesSessionUntagged()
{
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(),
clientIdentity: "MyKey Display",
ownerKeyId: "key-abc123",
ownerDashboardTags: null,
CancellationToken.None);
Assert.Empty(session.Tags);
GatewaySession tagless = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-1",
ownerKeyId: null,
CancellationToken.None);
Assert.Empty(tagless.Tags);
}
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -196,6 +196,64 @@ public sealed class ApiKeyAdminCommandLineParserTests
Assert.True(constraints.ReadHistorizedOnly);
}
/// <summary>
/// Verifies --dashboard-tags parses a comma-separated list, trimming segments and unioning
/// repeated occurrences of the flag without duplicating a tag that differs only by case.
/// </summary>
[Fact]
public void Parse_CreateKeyCommand_WithDashboardTags_ParsesTrimmedTagList()
{
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
[
"apikey",
"create-key",
"--key-id",
"operator01",
"--display-name",
"Operator",
"--dashboard-tags",
" team-a , team-b ",
"--dashboard-tags",
"TEAM-A,team-c"
]);
Assert.True(result.IsApiKeyCommand);
Assert.Null(result.Error);
Assert.NotNull(result.Command);
Assert.Equal(["team-a", "team-b", "team-c"], result.Command.Constraints.DashboardTags);
}
/// <summary>Verifies a create-key command without --dashboard-tags leaves the key untagged.</summary>
[Fact]
public void Parse_CreateKeyCommand_WithoutDashboardTags_LeavesKeyUntagged()
{
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator"]);
Assert.NotNull(result.Command);
Assert.Empty(result.Command.Constraints.DashboardTags);
}
/// <summary>
/// Verifies an empty tag segment is rejected rather than dropped: a stray comma must not
/// silently persist a grant the operator did not write.
/// </summary>
[Theory]
[InlineData("team-a,,team-b")]
[InlineData("team-a, ")]
[InlineData("")]
public void Parse_CreateKeyCommand_WithEmptyDashboardTag_Fails(string tags)
{
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator",
$"--dashboard-tags={tags}"]);
Assert.True(result.IsApiKeyCommand);
Assert.Null(result.Command);
Assert.NotNull(result.Error);
Assert.Contains("--dashboard-tags", result.Error, StringComparison.Ordinal);
}
/// <summary>Verifies that create-key command without display name returns error.</summary>
[Fact]
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
@@ -0,0 +1,110 @@
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyConstraintSerializerTests
{
/// <summary>Verifies that dashboard tags survive a serialize/deserialize round trip.</summary>
[Fact]
public void RoundTrip_WithDashboardTags_PreservesTags()
{
ApiKeyConstraints constraints = ApiKeyConstraints.Empty with
{
ReadSubtrees = ["Area1/*"],
DashboardTags = ["team-a", "team-b"],
};
string? json = ApiKeyConstraintSerializer.Serialize(constraints);
Assert.NotNull(json);
Assert.Contains("dashboard_tags", json, StringComparison.Ordinal);
ApiKeyConstraints restored = ApiKeyConstraintSerializer.Deserialize(json);
Assert.Equal(["team-a", "team-b"], restored.DashboardTags);
Assert.Equal(["Area1/*"], restored.ReadSubtrees);
}
/// <summary>
/// Verifies a key whose only per-key policy is a dashboard tag is still persisted: the
/// serializer drops empty constraints entirely, so the tag must count as non-empty.
/// </summary>
[Fact]
public void Serialize_WithOnlyDashboardTags_IsNotTreatedAsEmpty()
{
ApiKeyConstraints constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] };
Assert.False(constraints.IsEmpty);
Assert.NotNull(ApiKeyConstraintSerializer.Serialize(constraints));
}
/// <summary>Verifies that dashboard tags never register as read or write (data-access) constraints.</summary>
[Fact]
public void DashboardTags_AreNotDataAccessConstraints()
{
ApiKeyConstraints constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] };
Assert.False(constraints.HasReadConstraints);
Assert.False(constraints.HasWriteConstraints);
}
/// <summary>
/// Verifies a row persisted before the dashboard-tag field existed still deserializes, with
/// every pre-existing constraint intact and an untagged (empty, never null) tag list.
/// </summary>
[Fact]
public void Deserialize_LegacyJsonWithoutDashboardTags_YieldsUntaggedConstraints()
{
const string LegacyJson = """
{
"read_subtrees": ["Area1/*"],
"write_subtrees": [],
"read_tag_globs": [],
"write_tag_globs": ["Pump_*"],
"max_write_classification": 2,
"browse_subtrees": ["Area1/*"],
"read_alarm_only": true,
"read_historized_only": false
}
""";
ApiKeyConstraints constraints = ApiKeyConstraintSerializer.Deserialize(LegacyJson);
Assert.Empty(constraints.DashboardTags);
Assert.Equal(["Area1/*"], constraints.ReadSubtrees);
Assert.Equal(["Pump_*"], constraints.WriteTagGlobs);
Assert.Equal(2, constraints.MaxWriteClassification);
Assert.Equal(["Area1/*"], constraints.BrowseSubtrees);
Assert.True(constraints.ReadAlarmOnly);
Assert.False(constraints.ReadHistorizedOnly);
}
/// <summary>Verifies an explicit JSON null for the tag list normalizes to untagged rather than null.</summary>
[Fact]
public void Deserialize_ExplicitNullDashboardTags_YieldsEmptyList()
{
const string Json = """
{
"read_subtrees": [],
"write_subtrees": [],
"read_tag_globs": [],
"write_tag_globs": [],
"max_write_classification": null,
"browse_subtrees": [],
"read_alarm_only": false,
"read_historized_only": false,
"dashboard_tags": null
}
""";
Assert.Empty(ApiKeyConstraintSerializer.Deserialize(Json).DashboardTags);
}
/// <summary>Verifies null or whitespace constraint JSON deserializes to the untagged empty instance.</summary>
[Fact]
public void Deserialize_NullJson_YieldsEmptyConstraints()
{
Assert.Same(ApiKeyConstraints.Empty, ApiKeyConstraintSerializer.Deserialize(null));
Assert.Empty(ApiKeyConstraints.Empty.DashboardTags);
}
}