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