File: Secrets\RadiusSecretStoreValidation.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRERADIUS006 // Secret-store types are experimental; consumed internally by the integration.
#pragma warning disable ASPIREPIPELINES001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Radius.Publishing;
 
namespace Aspire.Hosting.Radius.Secrets;
 
/// <summary>
/// Config-time (pre-publish/deploy) validation of Radius secret stores. Runs as a
/// fail-fast gate that is <c>RequiredBy</c> both publish and deploy, so type/mode/key/
/// encoding/duplicate-name failures surface before any Bicep is emitted or
/// <c>kubectl</c>/<c>rad</c> is contacted. Store-name grammar and empty/whitespace keys
/// are rejected earlier, at the builder call site (FR-005a). No-op when the model
/// declares no secret stores (the feature is inactive; the default path is unchanged).
/// </summary>
internal static class RadiusSecretStoreValidation
{
    /// <summary>Pipeline-step entry point. No-ops in Run mode.</summary>
    internal static Task ValidateAsync(PipelineStepContext context)
    {
        ArgumentNullException.ThrowIfNull(context);
 
        if (context.ExecutionContext.IsRunMode)
        {
            return Task.CompletedTask;
        }
 
        Validate(context.Model);
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Pipeline-step entry point for the orphaned application-scoped store gate. No-ops in Run mode.
    /// </summary>
    /// <remarks>
    /// Application-scoped stores are emitted and validated by a Radius environment's publish/deploy
    /// steps. Those steps only exist when the model contains at least one <see cref="RadiusEnvironmentResource"/>,
    /// so an app-scoped store declared without any Radius environment would otherwise be silently
    /// dropped (never emitted, never validated). This gate is registered directly on the store
    /// resource so it runs regardless of environments and fails fast when no environment is present.
    /// </remarks>
    internal static Task ValidateHasEnvironmentAsync(PipelineStepContext context)
    {
        ArgumentNullException.ThrowIfNull(context);
 
        if (context.ExecutionContext.IsRunMode)
        {
            return Task.CompletedTask;
        }
 
        ValidateHasEnvironment(context.Model);
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Rejects an application-scoped secret store declared without any Radius environment
    /// (<c>ASPIRERADIUS068</c>).
    /// </summary>
    internal static void ValidateHasEnvironment(DistributedApplicationModel model)
    {
        ArgumentNullException.ThrowIfNull(model);
 
        if (model.Resources.OfType<RadiusEnvironmentResource>().Any())
        {
            return;
        }
 
        var orphaned = model.Resources
            .OfType<RadiusSecretStoreResource>()
            .Where(static s => s.Scope == RadiusSecretStoreScope.Application)
            .Select(static s => s.Name)
            .ToList();
 
        if (orphaned.Count > 0)
        {
            throw new InvalidOperationException(
                $"Application-scoped Radius secret store(s) '{string.Join("', '", orphaned)}' were declared " +
                "but the model contains no Radius environment. Application-scoped stores are emitted and " +
                "deployed by a Radius environment; add one with AddRadiusEnvironment. Diagnostic: ASPIRERADIUS068.");
        }
    }
 
    /// <summary>
    /// Validates every declared secret store over the whole application model.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// A required key is missing (<c>ASPIRERADIUS040</c>), the population mode count is not
    /// exactly one (<c>ASPIRERADIUS041</c>), an inline key binds a non-secret parameter
    /// (<c>ASPIRERADIUS042</c>), a duplicate key is declared (<c>ASPIRERADIUS043</c>), an
    /// invalid encoding is set for the type (<c>ASPIRERADIUS047</c>), two stores share a
    /// Bicep identifier within the same emitted scope (<c>ASPIRERADIUS048</c>), an
    /// application-scoped existing store uses a namespace-less reference (<c>ASPIRERADIUS055</c>),
    /// or a non-sealed store sets <c>WithMaterializationTimeout</c> (<c>ASPIRERADIUS062</c>).
    /// </exception>
    internal static void Validate(DistributedApplicationModel model)
    {
        ArgumentNullException.ThrowIfNull(model);
 
        var stores = model.Resources.OfType<RadiusSecretStoreResource>().ToList();
        if (stores.Count == 0)
        {
            return;
        }
 
        foreach (var store in stores)
        {
            ValidateStore(store);
        }
 
        ValidateNoDuplicateNames(stores);
        ValidateConsumers(model);
    }
 
    private static void ValidateStore(RadiusSecretStoreResource store)
    {
        var population = store.Population;
 
        // ASPIRERADIUS041 — exactly one population mode.
        if (population.DeclaredModeCount != 1)
        {
            throw new InvalidOperationException(
                $"Secret store '{store.Name}' must declare exactly one population mode " +
                "(WithData, WithExistingSecret, or WithSealedSecret); it declares " +
                $"{population.DeclaredModeCount}. Diagnostic: ASPIRERADIUS041.");
        }
 
        var declaredKeys = population.HasInlineData
            ? population.Data.Keys.ToList()
            : population.Keys;
 
        // ASPIRERADIUS043 — duplicate keys. Inline keys are rejected as they are added (the data
        // dictionary rejects a duplicate via RadiusSecretStoreDataBuilder.Add), so only the
        // existing/sealed key list needs a duplicate scan here.
        if (population.IsSecretReference)
        {
            var seen = new HashSet<string>(StringComparer.Ordinal);
            foreach (var key in population.Keys)
            {
                if (!seen.Add(key))
                {
                    throw new InvalidOperationException(
                        $"Secret store '{store.Name}' declares the key '{key}' more than once. " +
                        "Diagnostic: ASPIRERADIUS043.");
                }
            }
        }
 
        // ASPIRERADIUS040 — type-aware required keys.
        foreach (var required in store.Type.RequiredKeys())
        {
            if (!declaredKeys.Contains(required, StringComparer.Ordinal))
            {
                throw new InvalidOperationException(
                    $"Secret store '{store.Name}' of type '{store.Type.ToRadiusTypeString()}' is missing " +
                    $"the required key '{required}'. Diagnostic: ASPIRERADIUS040.");
            }
        }
 
        // ASPIRERADIUS042 / ASPIRERADIUS047 — inline bindings must be secret and use valid encoding.
        if (population.HasInlineData)
        {
            foreach (var (key, binding) in population.Data)
            {
                if (!binding.Parameter.Secret)
                {
                    throw new InvalidOperationException(
                        $"Secret store '{store.Name}' binds key '{key}' to the non-secret parameter " +
                        $"'{binding.Parameter.Name}'. Bind a parameter created with secret: true. " +
                        "Diagnostic: ASPIRERADIUS042.");
                }
 
                if (binding.Encoding is not null && !store.Type.IsValidEncoding(binding.Encoding))
                {
                    throw new InvalidOperationException(
                        $"Secret store '{store.Name}' sets encoding '{binding.Encoding}' on key '{key}', which is " +
                        $"invalid for a '{store.Type.ToRadiusTypeString()}' store. Diagnostic: ASPIRERADIUS047.");
                }
            }
        }
 
        // ASPIRERADIUS062 — WithMaterializationTimeout only affects the sealed-secret deploy path,
        // which awaits the SealedSecret controller. On any other population mode it would silently
        // no-op, so reject an explicit override rather than mislead the author.
        if (store.MaterializationTimeoutWasSet && !population.HasSealedSecret)
        {
            throw new InvalidOperationException(
                $"Secret store '{store.Name}' sets WithMaterializationTimeout but is not populated with " +
                "WithSealedSecret. The materialization timeout only applies to sealed secrets; remove the " +
                "call or use WithSealedSecret. Diagnostic: ASPIRERADIUS062.");
        }
 
        // ASPIRERADIUS055 — an application-scoped existing-secret store has no single owning environment,
        // so a bare '<name>' reference has no deterministic namespace to default to (it would otherwise
        // fall back to whichever environment happens to build the store). Require a fully-qualified
        // '<namespace>/<name>' reference. Sealed stores are checked after their manifest metadata is read
        // because only then can we tell whether metadata.namespace was explicit or defaulted.
        if (store.Scope == RadiusSecretStoreScope.Application &&
            population.HasExistingSecret &&
            population.ResourceReference is { } reference &&
            !reference.Contains('/', StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                $"Application-scoped secret store '{store.Name}' references the existing Secret '{reference}' " +
                "without a namespace. Application-scoped stores have no owning environment to default the " +
                "namespace from; use a fully-qualified '<namespace>/<name>' reference. " +
                "Diagnostic: ASPIRERADIUS055.");
        }
    }
 
    /// <summary>
    /// Validates every recorded secret-store consumer wiring across the model.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// A consumer kind is incompatible with the store type (<c>ASPIRERADIUS051</c>), an
    /// <c>envSecrets</c> consumer references a key the store does not declare
    /// (<c>ASPIRERADIUS052</c>), or a key-specific <c>envSecrets</c> consumer references a store
    /// that declares no keys (<c>ASPIRERADIUS064</c>).
    /// </exception>
    private static void ValidateConsumers(DistributedApplicationModel model)
    {
        foreach (var resource in model.Resources)
        {
            var annotation = resource.Annotations.OfType<Annotations.RadiusSecretStoresAnnotation>().FirstOrDefault();
            if (annotation is null)
            {
                continue;
            }
 
            foreach (var consumer in annotation.Consumers)
            {
                ValidateConsumer(consumer);
            }
        }
    }
 
    private static void ValidateConsumer(RadiusSecretStoreConsumer consumer)
    {
        var store = consumer.Store;
 
        // ASPIRERADIUS051 — a Bicep private-registry auth consumer references a basicAuthentication
        // (username/password) store, matching the OCI registry credential shape. envSecrets can source
        // from any type, so it is unconstrained here (its per-key check is below).
        if (consumer.Kind == RadiusSecretStoreConsumerKind.BicepRegistryAuth &&
            store.Type != RadiusSecretStoreType.BasicAuthentication)
        {
            throw new InvalidOperationException(
                $"Secret store '{store.Name}' of type '{store.Type.ToRadiusTypeString()}' is referenced as a " +
                $"{DescribeKind(consumer.Kind)} consumer, which requires a '{RadiusSecretStoreType.BasicAuthentication.ToRadiusTypeString()}' store. " +
                "Diagnostic: ASPIRERADIUS051.");
        }
 
        // ASPIRERADIUS051 — a Terraform Git PAT consumer references a store that must expose a 'pat' key
        // (optionally with 'username'); this is the shape Radius reads for
        // recipeConfig.terraform.authentication.git.pat, and it is typically a 'generic' store — NOT a
        // basicAuthentication (username/password) store, whose 'password' key Radius never consumes here.
        // See https://docs.radapp.io/guides/recipes/terraform/howto-private-registry/. Only enforce when
        // the store declares its keys inline/explicitly; an existing/sealed store that materializes keys
        // out-of-band is left unchecked (consistent with the envSecrets keyless handling below).
        if (consumer.Kind == RadiusSecretStoreConsumerKind.TerraformGitPat)
        {
            var declaredKeys = store.Population.HasInlineData
                ? store.Population.Data.Keys.ToList()
                : store.Population.Keys;
 
            if (declaredKeys.Count > 0 && !declaredKeys.Contains("pat", StringComparer.Ordinal))
            {
                throw new InvalidOperationException(
                    $"Secret store '{store.Name}' is referenced as a {DescribeKind(consumer.Kind)} consumer but does not " +
                    "declare the required 'pat' key (optionally with 'username'). Use a store that exposes a 'pat' key " +
                    $"(typically a '{RadiusSecretStoreType.Generic.ToRadiusTypeString()}' store). Declared keys: " +
                    $"{string.Join(", ", declaredKeys)}. Diagnostic: ASPIRERADIUS051.");
            }
        }
 
        // ASPIRERADIUS052 / ASPIRERADIUS064 — a key-specific envSecrets consumer must reference a key the
        // store exposes. Emission only exposes explicitly declared keys, so:
        //   * a keyless store (no inline data and no existing/sealed key list) cannot satisfy a
        //     key-specific reference — the emitted envSecrets entry would dangle (ASPIRERADIUS064);
        //   * a store with a non-empty declared set must contain the referenced key (ASPIRERADIUS052).
        // A store with no declared keys that is referenced WITHOUT a specific key is left alone: such a
        // sealed/existing store materializes its keys out-of-band and is intentionally unchecked.
        if (consumer.Kind == RadiusSecretStoreConsumerKind.EnvSecret && consumer.Key is { } key)
        {
            var declaredKeys = store.Population.HasInlineData
                ? store.Population.Data.Keys.ToList()
                : store.Population.Keys;
 
            if (declaredKeys.Count == 0)
            {
                throw new InvalidOperationException(
                    $"Secret store '{store.Name}' declares no keys, but the recipe environment secret " +
                    $"'{consumer.Selector}' references the key '{key}'. A key-specific envSecrets consumer requires " +
                    "the store to declare that key (via WithData, or WithExistingSecret/WithSealedSecret with keys). " +
                    "Diagnostic: ASPIRERADIUS064.");
            }
 
            if (!declaredKeys.Contains(key, StringComparer.Ordinal))
            {
                throw new InvalidOperationException(
                    $"Secret store '{store.Name}' does not declare the key '{key}' referenced by the recipe " +
                    $"environment secret '{consumer.Selector}'. Declared keys: {string.Join(", ", declaredKeys)}. " +
                    "Diagnostic: ASPIRERADIUS052.");
            }
        }
    }
 
    private static string DescribeKind(RadiusSecretStoreConsumerKind kind) => kind switch
    {
        RadiusSecretStoreConsumerKind.BicepRegistryAuth => "Bicep registry authentication",
        RadiusSecretStoreConsumerKind.TerraformGitPat => "Terraform Git PAT authentication",
        RadiusSecretStoreConsumerKind.EnvSecret => "recipe environment secret",
        _ => kind.ToString(),
    };
 
    /// <summary>Validates that an application-scoped sealed store has deterministic manifest namespace metadata.</summary>
    /// <exception cref="InvalidOperationException">
    /// The store is application-scoped and its sealed manifest omitted <c>metadata.namespace</c> (<c>ASPIRERADIUS055</c>).
    /// </exception>
    internal static void ValidateSealedSecretNamespace(
        RadiusSecretStoreResource store,
        SealedSecretManifest.Metadata metadata,
        string manifestPath)
    {
        if (store.Scope == RadiusSecretStoreScope.Application &&
            store.Population.HasSealedSecret &&
            !metadata.NamespaceWasExplicit)
        {
            throw new InvalidOperationException(
                $"Application-scoped secret store '{store.Name}' references the SealedSecret manifest at " +
                $"'{manifestPath}' without metadata.namespace. Application-scoped stores have no owning " +
                "environment to default the namespace from; set metadata.namespace in the manifest. " +
                "Diagnostic: ASPIRERADIUS055.");
        }
    }
 
    private static void ValidateNoDuplicateNames(IReadOnlyList<RadiusSecretStoreResource> stores)
    {
        // Aspire already enforces unique resource names, so exact-name collisions cannot occur.
        // Two distinct store names can still sanitize to the same Bicep identifier (e.g. 'db-creds'
        // and 'db.creds' both become 'db_creds'), which would emit duplicate resource symbols.
        // Application-scoped stores are emitted into every environment's app.bicep, so they must
        // also be checked against every environment-scoped identifier, not just other app stores.
        var appScoped = new Dictionary<string, RadiusSecretStoreResource>(StringComparer.Ordinal);
        var envScoped = new Dictionary<(string? EnvironmentName, string Identifier), RadiusSecretStoreResource>();
 
        foreach (var store in stores)
        {
            var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name);
 
            if (store.Scope == RadiusSecretStoreScope.Application)
            {
                if (appScoped.TryGetValue(identifier, out var appCollision))
                {
                    ThrowDuplicateName(appCollision, store, identifier);
                }
 
                var envCollision = envScoped.FirstOrDefault(kvp => string.Equals(kvp.Key.Identifier, identifier, StringComparison.Ordinal));
                if (envCollision.Value is not null)
                {
                    ThrowDuplicateName(envCollision.Value, store, identifier);
                }
 
                appScoped[identifier] = store;
                continue;
            }
 
            var envKey = (store.OwningEnvironment?.Name, identifier);
            if (envScoped.TryGetValue(envKey, out var sameEnvironmentCollision))
            {
                ThrowDuplicateName(sameEnvironmentCollision, store, identifier);
            }
 
            if (appScoped.TryGetValue(identifier, out var applicationCollision))
            {
                ThrowDuplicateName(applicationCollision, store, identifier);
            }
 
            envScoped[envKey] = store;
        }
    }
 
    private static void ThrowDuplicateName(
        RadiusSecretStoreResource existing,
        RadiusSecretStoreResource store,
        string identifier)
    {
        throw new InvalidOperationException(
            $"Secret stores '{existing.Name}' and '{store.Name}' map to the same Bicep identifier " +
            $"'{identifier}' within the same emitted scope. Rename one so they produce distinct identifiers. " +
            "Diagnostic: ASPIRERADIUS048.");
    }
}