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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user