File: Backchannel\SecretRedactionHistory.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// AppHost-scoped, add-only history of the secret parameters and resolved secret values that any
/// <c>aspire describe</c>/<c>watch</c> backchannel connection has ever observed, so they stay redacted from data
/// sent to clients (https://github.com/microsoft/aspire/issues/19241).
/// </summary>
/// <remarks>
/// Registered as a singleton for the lifetime of the AppHost and shared by every
/// <see cref="AuxiliaryBackchannelRpcTarget"/> (one is created per connection). The redaction set must outlive an
/// individual connection: a resource's secret can change while the app runs, and a snapshot carrying an older value
/// can be emitted to a client that connected <em>after</em> the change. If each connection tracked history on its
/// own, a freshly connected client's target would start empty and leak that older value, so history is accumulated
/// once per AppHost instead.
/// <para>
/// Both sets only ever grow:
/// </para>
/// <list type="bullet">
/// <item>
/// The parameter set grows because when DCP restarts a resource it forgets and re-evaluates the resource's callbacks
/// (<c>DcpExecutor.ForgetCachedCallbackResults</c>), which can swap which secret a resource references. A
/// still-in-flight snapshot from the prior incarnation can carry the old secret, so we keep trying to resolve every
/// secret parameter ever observed rather than only the current pass's set.
/// </item>
/// <item>
/// The value set grows because a parameter's resolved value can be replaced in place: the runtime "Set parameter"
/// path swaps a completed <see cref="ParameterResource.WaitForValueTcs"/> for a new one
/// (<c>ParameterProcessor.SetParameterValue</c>), so re-resolving a retained parameter later yields only the new
/// value. An already-published or still-current snapshot can still carry the previous value, so we must keep
/// redacting every secret string ever resolved or that old value would be emitted in plaintext.
/// </item>
/// </list>
/// <para>
/// A backchannel connection only observes a secret value once it is open, so a value assigned and then reassigned
/// before the first connection would otherwise be absent from the history and leak from a lagging snapshot. To close
/// that cold-start residual, <c>ParameterProcessor</c> also records secret values into this history at the moment it
/// assigns or replaces them (see its <c>SecretRedactionHistory</c> wiring), independent of any connection. Values
/// that never flow through the parameter processor still rely on connection-time observation.
/// </para>
/// </remarks>
internal sealed class SecretRedactionHistory
{
    // Collected by reference: parameter resources referenced by annotations are not registered in the model and so
    // are not subject to its unique-name constraint, and distinct same-named secrets must be preserved.
    private readonly HashSet<ParameterResource> _parameters = new(ReferenceEqualityComparer.Instance);
    private readonly HashSet<string> _values = new(StringComparer.Ordinal);
    private readonly object _lock = new();
 
    /// <summary>
    /// Merges <paramref name="parameters"/> into the history and returns a snapshot of every secret parameter seen so
    /// far. Pass an empty sequence to read the current snapshot without adding.
    /// </summary>
    public IReadOnlyList<ParameterResource> AddParametersAndSnapshot(IEnumerable<ParameterResource> parameters)
    {
        lock (_lock)
        {
            _parameters.UnionWith(parameters);
            return [.. _parameters];
        }
    }
 
    /// <summary>
    /// Merges <paramref name="values"/> into the history and returns a fresh snapshot of every resolved secret value
    /// seen so far, for membership testing while redacting.
    /// </summary>
    public HashSet<string> AddValuesAndSnapshot(IEnumerable<string> values)
    {
        lock (_lock)
        {
            _values.UnionWith(values);
            return new HashSet<string>(_values, StringComparer.Ordinal);
        }
    }
 
    /// <summary>
    /// Merges <paramref name="values"/> into the history without allocating a snapshot. Used to record secret values
    /// as they are assigned (see <c>ParameterProcessor</c>), where the caller only writes and never needs to read the
    /// set back.
    /// </summary>
    public void AddValues(IEnumerable<string> values)
    {
        lock (_lock)
        {
            _values.UnionWith(values);
        }
    }
}