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:
+15
-2
@@ -263,6 +263,7 @@ mxgateway apikey init-db
|
|||||||
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
|
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
|
||||||
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
||||||
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d
|
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d
|
||||||
|
mxgateway apikey create-key --key-id team-a.svc --display-name "Team A service" --scopes session:open,invoke:read --dashboard-tags team-a
|
||||||
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
|
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
|
||||||
mxgateway apikey list-keys --json
|
mxgateway apikey list-keys --json
|
||||||
mxgateway apikey revoke-key --key-id ops.alice
|
mxgateway apikey revoke-key --key-id ops.alice
|
||||||
@@ -272,8 +273,20 @@ mxgateway apikey rotate-key --key-id ops.alice
|
|||||||
Constraint flags are optional. `--read-subtree`, `--write-subtree`,
|
Constraint flags are optional. `--read-subtree`, `--write-subtree`,
|
||||||
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
|
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
|
||||||
`--max-write-classification` accepts one integer. `--read-alarm-only` and
|
`--max-write-classification` accepts one integer. `--read-alarm-only` and
|
||||||
`--read-historized-only` are boolean flags. Existing rows with null constraints
|
`--read-historized-only` are boolean flags. `--dashboard-tags` takes a
|
||||||
remain fully unconstrained after migration.
|
comma-separated list (`--dashboard-tags team-a,team-b`) and is repeatable; its
|
||||||
|
segments are trimmed and de-duplicated ordinal-ignore-case, and an empty segment
|
||||||
|
is rejected rather than dropped so a stray comma cannot silently persist a grant
|
||||||
|
the operator did not write. Existing rows with null constraints remain fully
|
||||||
|
unconstrained after migration; rows written before `--dashboard-tags` existed
|
||||||
|
deserialize as untagged, unchanged in every other respect.
|
||||||
|
|
||||||
|
`--dashboard-tags` is *not* a data-access constraint — it only labels the key for
|
||||||
|
dashboard event visibility, and sessions the key opens inherit it. See
|
||||||
|
[Authorization](./Authorization.md#constraint-enforcement).
|
||||||
|
|
||||||
|
`list-keys` prints the tags as a trailing tab-separated column (`-` when
|
||||||
|
untagged); the values are operator-chosen labels, not key material.
|
||||||
|
|
||||||
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
|
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
|
||||||
so they remain safe to embed in the token format and in URL paths used by
|
so they remain safe to embed in the token format and in URL paths used by
|
||||||
|
|||||||
@@ -178,6 +178,30 @@ Supported constraints are:
|
|||||||
| `browse_subtrees` | Contained-path globs used to filter Galaxy browse results and deploy-event counts. |
|
| `browse_subtrees` | Contained-path globs used to filter Galaxy browse results and deploy-event counts. |
|
||||||
| `read_alarm_only` | Read/subscription commands must target objects with alarm-bearing attributes. |
|
| `read_alarm_only` | Read/subscription commands must target objects with alarm-bearing attributes. |
|
||||||
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
|
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
|
||||||
|
| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. |
|
||||||
|
|
||||||
|
`dashboard_tags` is the one member of the blob that constrains nothing on the
|
||||||
|
gRPC data path. No read, write, browse, or subscribe check consults it, and
|
||||||
|
`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag
|
||||||
|
neither widens nor narrows what a key may read or write. It rides in the same
|
||||||
|
serialized blob only to avoid an auth-store schema migration
|
||||||
|
(`docs/plans/2026-07-10-dashboard-session-acl-tst15.md` §3.1).
|
||||||
|
|
||||||
|
Its sole purpose is dashboard event visibility. A session records the tags of the
|
||||||
|
API key that opened it (`GatewaySession.Tags`, immutable for the session's life,
|
||||||
|
compared ordinal-ignore-case). The tags come from the owning key, never from the
|
||||||
|
client's `OpenSession` request, so a client cannot label its own session with
|
||||||
|
another tenant's tag. A key with no tags opens untagged sessions.
|
||||||
|
|
||||||
|
Tags are set at key creation with
|
||||||
|
`apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are
|
||||||
|
trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard
|
||||||
|
API Keys page are currently always untagged.
|
||||||
|
|
||||||
|
The tag is carried end to end today; the dashboard ACL that consumes it — scoping
|
||||||
|
a Viewer's `EventsHub` subscriptions to the sessions their LDAP groups are
|
||||||
|
granted — is a separate change. Until it lands, the tag affects nothing at
|
||||||
|
runtime.
|
||||||
|
|
||||||
Glob matching is anchored, case-insensitive, and supports `*` and `?`.
|
Glob matching is anchored, case-insensitive, and supports `*` and `?`.
|
||||||
Subtree and tag glob lists are alternatives: matching either list allows that
|
Subtree and tag glob lists are alternatives: matching either list allows that
|
||||||
|
|||||||
@@ -32,11 +32,17 @@ public sealed class MxAccessGatewayService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
requestValidator.ValidateOpenSession(request);
|
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
|
GatewaySession session = await sessionManager
|
||||||
.OpenSessionAsync(
|
.OpenSessionAsync(
|
||||||
SessionOpenRequest.FromContract(request),
|
SessionOpenRequest.FromContract(request),
|
||||||
ResolveClientIdentity(),
|
ResolveClientIdentity(),
|
||||||
identityAccessor.Current?.KeyId,
|
owner?.KeyId,
|
||||||
|
owner?.EffectiveConstraints.DashboardTags,
|
||||||
context.CancellationToken)
|
context.CancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,13 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
|
|||||||
string expiry = key.ExpiresUtc is { } expires
|
string expiry = key.ExpiresUtc is { } expires
|
||||||
? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture)
|
? 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(
|
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);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -233,7 +233,45 @@ public static class ApiKeyAdminCommandLineParser
|
|||||||
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
|
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
|
||||||
BrowseSubtrees: GetOptions(options, "browse-subtree"),
|
BrowseSubtrees: GetOptions(options, "browse-subtree"),
|
||||||
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
|
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
|
// 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>
|
/// <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>
|
/// <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>
|
/// <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)
|
public static ApiKeyConstraints Deserialize(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ public sealed record ApiKeyConstraints(
|
|||||||
bool ReadAlarmOnly,
|
bool ReadAlarmOnly,
|
||||||
bool ReadHistorizedOnly)
|
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>
|
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
|
||||||
public static ApiKeyConstraints Empty { get; } = new(
|
public static ApiKeyConstraints Empty { get; } = new(
|
||||||
ReadSubtrees: Array.Empty<string>(),
|
ReadSubtrees: Array.Empty<string>(),
|
||||||
@@ -22,6 +54,11 @@ public sealed record ApiKeyConstraints(
|
|||||||
ReadHistorizedOnly: false);
|
ReadHistorizedOnly: false);
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
|
/// <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 =>
|
public bool IsEmpty =>
|
||||||
ReadSubtrees.Count == 0
|
ReadSubtrees.Count == 0
|
||||||
&& WriteSubtrees.Count == 0
|
&& WriteSubtrees.Count == 0
|
||||||
@@ -30,7 +67,8 @@ public sealed record ApiKeyConstraints(
|
|||||||
&& MaxWriteClassification is null
|
&& MaxWriteClassification is null
|
||||||
&& BrowseSubtrees.Count == 0
|
&& BrowseSubtrees.Count == 0
|
||||||
&& !ReadAlarmOnly
|
&& !ReadAlarmOnly
|
||||||
&& !ReadHistorizedOnly;
|
&& !ReadHistorizedOnly
|
||||||
|
&& DashboardTags.Count == 0;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
|
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
|
||||||
public bool HasReadConstraints =>
|
public bool HasReadConstraints =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -12,6 +13,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
|
|||||||
|
|
||||||
public sealed class GatewaySession
|
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 object _syncRoot = new();
|
||||||
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
private readonly SessionEventStreaming _eventStreaming;
|
private readonly SessionEventStreaming _eventStreaming;
|
||||||
@@ -149,6 +154,12 @@ public sealed class GatewaySession
|
|||||||
/// <see cref="MarkFaulted"/> using <paramref name="eventStreaming"/>'s clock so the timer
|
/// <see cref="MarkFaulted"/> using <paramref name="eventStreaming"/>'s clock so the timer
|
||||||
/// is unit-testable.
|
/// is unit-testable.
|
||||||
/// </param>
|
/// </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(
|
public GatewaySession(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
string backendName,
|
string backendName,
|
||||||
@@ -167,7 +178,8 @@ public sealed class GatewaySession
|
|||||||
TimeSpan detachGrace = default,
|
TimeSpan detachGrace = default,
|
||||||
TimeSpan workerReadyWaitTimeout = default,
|
TimeSpan workerReadyWaitTimeout = default,
|
||||||
ArrayAddressNormalizer? addressNormalizer = null,
|
ArrayAddressNormalizer? addressNormalizer = null,
|
||||||
TimeSpan faultedGrace = default)
|
TimeSpan faultedGrace = default,
|
||||||
|
IReadOnlyList<string>? ownerDashboardTags = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(sessionId))
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
{
|
{
|
||||||
@@ -195,6 +207,9 @@ public sealed class GatewaySession
|
|||||||
Nonce = nonce;
|
Nonce = nonce;
|
||||||
ClientIdentity = clientIdentity;
|
ClientIdentity = clientIdentity;
|
||||||
OwnerKeyId = ownerKeyId;
|
OwnerKeyId = ownerKeyId;
|
||||||
|
Tags = ownerDashboardTags is { Count: > 0 }
|
||||||
|
? ownerDashboardTags.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
|
||||||
|
: EmptyTags;
|
||||||
ClientSessionName = clientSessionName;
|
ClientSessionName = clientSessionName;
|
||||||
ClientCorrelationId = clientCorrelationId;
|
ClientCorrelationId = clientCorrelationId;
|
||||||
CommandTimeout = commandTimeout;
|
CommandTimeout = commandTimeout;
|
||||||
@@ -241,6 +256,19 @@ public sealed class GatewaySession
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? OwnerKeyId { get; }
|
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>
|
/// <summary>
|
||||||
/// Gets the client-supplied session name.
|
/// Gets the client-supplied session name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ public interface ISessionManager
|
|||||||
string? ownerKeyId,
|
string? ownerKeyId,
|
||||||
CancellationToken cancellationToken);
|
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>
|
/// <summary>Attempts to retrieve a session by ID.</summary>
|
||||||
/// <param name="sessionId">Identifier of the session.</param>
|
/// <param name="sessionId">Identifier of the session.</param>
|
||||||
/// <param name="session">The retrieved session, if found.</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);
|
_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 />
|
/// <inheritdoc />
|
||||||
public async Task<GatewaySession> OpenSessionAsync(
|
public async Task<GatewaySession> OpenSessionAsync(
|
||||||
SessionOpenRequest request,
|
SessionOpenRequest request,
|
||||||
string? clientIdentity,
|
string? clientIdentity,
|
||||||
string? ownerKeyId,
|
string? ownerKeyId,
|
||||||
|
IReadOnlyList<string>? ownerDashboardTags,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -101,7 +110,7 @@ public sealed class SessionManager : ISessionManager
|
|||||||
bool sessionOpenedRecorded = false;
|
bool sessionOpenedRecorded = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
session = CreateSession(request, clientIdentity, ownerKeyId);
|
session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags);
|
||||||
if (!_registry.TryAdd(session))
|
if (!_registry.TryAdd(session))
|
||||||
{
|
{
|
||||||
throw new SessionManagerException(
|
throw new SessionManagerException(
|
||||||
@@ -494,7 +503,8 @@ public sealed class SessionManager : ISessionManager
|
|||||||
private GatewaySession CreateSession(
|
private GatewaySession CreateSession(
|
||||||
SessionOpenRequest request,
|
SessionOpenRequest request,
|
||||||
string? clientIdentity,
|
string? clientIdentity,
|
||||||
string? ownerKeyId)
|
string? ownerKeyId,
|
||||||
|
IReadOnlyList<string>? ownerDashboardTags)
|
||||||
{
|
{
|
||||||
string sessionUid = Guid.NewGuid().ToString("N");
|
string sessionUid = Guid.NewGuid().ToString("N");
|
||||||
string sessionId = $"session-{sessionUid}";
|
string sessionId = $"session-{sessionUid}";
|
||||||
@@ -541,7 +551,8 @@ public sealed class SessionManager : ISessionManager
|
|||||||
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.DetachGraceSeconds)),
|
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.DetachGraceSeconds)),
|
||||||
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
|
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
|
||||||
_addressNormalizer,
|
_addressNormalizer,
|
||||||
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)));
|
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)),
|
||||||
|
ownerDashboardTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateClientCorrelationId(
|
private static string CreateClientCorrelationId(
|
||||||
|
|||||||
@@ -50,6 +50,45 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
|
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>
|
/// <summary>
|
||||||
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
|
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
|
||||||
/// service's own <c>ResolveSession</c> lookup. No <c>InvokeException</c> is
|
/// 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>
|
/// <summary>The last owner key id passed to OpenSessionAsync.</summary>
|
||||||
public string? LastOwnerKeyId { get; private set; }
|
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>
|
/// <summary>The last session ID the event stream service was asked to stream.</summary>
|
||||||
public string? LastReadEventsSessionId { get; private set; }
|
public string? LastReadEventsSessionId { get; private set; }
|
||||||
|
|
||||||
@@ -564,6 +606,19 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
|
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 />
|
/// <inheritdoc />
|
||||||
public bool TryGetSession(
|
public bool TryGetSession(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -109,6 +109,55 @@ public sealed class SessionManagerTests
|
|||||||
Assert.Null(session.OwnerKeyId);
|
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>
|
/// <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>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+58
@@ -196,6 +196,64 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
|||||||
Assert.True(constraints.ReadHistorizedOnly);
|
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>
|
/// <summary>Verifies that create-key command without display name returns error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
|
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
|
||||||
|
|||||||
+110
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user