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:
+58
@@ -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()
|
||||
|
||||
+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