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
@@ -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);
}
}