-
Notifications
You must be signed in to change notification settings - Fork 1
fix(mainnet): deny local file secrets store, source from env vars (#386) #459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
09a613d
27741f5
aeee185
807fffd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // ───────────────────────────────────────────────────────────── | ||
| // EnvironmentSecretsStore — read-only IAevatarSecretsStore backed by | ||
| // IConfiguration (env vars + non-secret config files). | ||
| // | ||
| // Used by hosts that must not persist secrets to local files | ||
| // (e.g. Aevatar.Mainnet.Host.Api). Secrets are expected to come from | ||
| // the deploy platform via AEVATAR_-prefixed environment variables. | ||
| // Set / Remove deliberately throw so a misconfigured caller fails | ||
| // fast at the call site rather than silently falling back to disk. | ||
| // ───────────────────────────────────────────────────────────── | ||
|
|
||
| using Microsoft.Extensions.Configuration; | ||
|
|
||
| namespace Aevatar.Configuration; | ||
|
|
||
| /// <summary> | ||
| /// Read-only secrets store that resolves keys from <see cref="IConfiguration"/>. | ||
| /// Mutation methods throw <see cref="InvalidOperationException"/> by design: | ||
| /// hosts opting into this store have explicitly disabled local file persistence. | ||
| /// </summary> | ||
| public sealed class EnvironmentSecretsStore : IAevatarSecretsStore | ||
| { | ||
| private readonly IConfiguration _configuration; | ||
|
|
||
| public EnvironmentSecretsStore(IConfiguration configuration) | ||
| { | ||
| _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); | ||
| } | ||
|
|
||
| public string? Get(string key) | ||
| { | ||
| if (string.IsNullOrEmpty(key)) return null; | ||
| var value = _configuration[key]; | ||
| return string.IsNullOrEmpty(value) ? null : value; | ||
| } | ||
|
|
||
| public string? GetApiKey(string providerName) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(providerName)) return null; | ||
|
|
||
| var byProvidersSection = _configuration[$"LLMProviders:Providers:{providerName}:ApiKey"]; | ||
| if (!string.IsNullOrWhiteSpace(byProvidersSection)) | ||
| return byProvidersSection; | ||
|
|
||
| var byProviderSection = _configuration[$"LLMProviders:{providerName}:ApiKey"]; | ||
| if (!string.IsNullOrWhiteSpace(byProviderSection)) | ||
| return byProviderSection; | ||
|
|
||
| var byEnvConvention = _configuration[$"{providerName}_API_KEY"]; | ||
| if (!string.IsNullOrWhiteSpace(byEnvConvention)) | ||
| return byEnvConvention; | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| public string? GetDefaultProvider() | ||
| { | ||
| var value = _configuration["LLMProviders:Default"]; | ||
| return string.IsNullOrWhiteSpace(value) ? null : value; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns a snapshot of secret-shaped configuration entries. Only keys | ||
| /// that match the conventions <see cref="GetApiKey"/> understands are | ||
| /// included: anything under <c>LLMProviders:</c> (provider definitions, | ||
| /// API keys, default name) and any <c>{NAME}_API_KEY</c>-style keys. | ||
| /// <para> | ||
| /// This is intentionally narrower than dumping the entire | ||
| /// <see cref="IConfiguration"/> view: in env-driven hosts, the config root | ||
| /// also contains binding URLs, feature flags and connection strings, none | ||
| /// of which belong on the secrets API surface. | ||
| /// </para> | ||
| /// </summary> | ||
| public IReadOnlyDictionary<string, string> GetAll() | ||
| { | ||
| var snapshot = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||
| foreach (var kv in _configuration.AsEnumerable()) | ||
| { | ||
| if (string.IsNullOrEmpty(kv.Value)) continue; | ||
| if (!IsSecretShapedKey(kv.Key)) continue; | ||
| snapshot[kv.Key] = kv.Value; | ||
| } | ||
| return snapshot; | ||
| } | ||
|
|
||
| private static bool IsSecretShapedKey(string key) => | ||
| key.StartsWith("LLMProviders:", StringComparison.OrdinalIgnoreCase) || | ||
| key.EndsWith("_API_KEY", StringComparison.OrdinalIgnoreCase); | ||
|
|
||
| public void Set(string key, string value) => | ||
| throw new InvalidOperationException( | ||
| "EnvironmentSecretsStore is read-only: this host disables local file secrets persistence. " + | ||
| "Inject secrets via AEVATAR_-prefixed environment variables or platform-managed configuration."); | ||
|
|
||
| public void Remove(string key) => | ||
| throw new InvalidOperationException( | ||
| "EnvironmentSecretsStore is read-only: this host disables local file secrets persistence. " + | ||
| "Manage secret removal via AEVATAR_-prefixed environment variables or platform-managed configuration."); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,23 +2,58 @@ | |
| // ServiceCollectionExtensions — Aevatar Config DI 注册 | ||
| // ───────────────────────────────────────────────────────────── | ||
|
|
||
| using Aevatar.Foundation.Abstractions.Credentials; | ||
| using Microsoft.Extensions.Configuration; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||
| using Aevatar.Foundation.Abstractions.Credentials; | ||
|
|
||
| namespace Aevatar.Configuration; | ||
|
|
||
| /// <summary>Aevatar Config 的 DI 注册扩展。</summary> | ||
| public static class ServiceCollectionExtensions | ||
| { | ||
| /// <summary> | ||
| /// 注册 Aevatar 配置服务。自动创建 ~/.aevatar/ 目录结构。 | ||
| /// 注册 Aevatar 配置服务。 | ||
| /// </summary> | ||
| public static IServiceCollection AddAevatarConfig(this IServiceCollection services) | ||
| /// <param name="services">DI 容器。</param> | ||
| /// <param name="allowLocalFileStore"> | ||
| /// 是否允许使用本地文件 secrets store(<see cref="AevatarSecretsStore"/>, | ||
| /// 读写 <c>~/.aevatar/secrets.json</c>)。 | ||
| /// <para> | ||
| /// <c>true</c>(默认):注册 <see cref="AevatarSecretsStore"/> 并自动创建 | ||
| /// <c>~/.aevatar/</c> 目录结构,沿用历史行为。 | ||
| /// </para> | ||
| /// <para> | ||
| /// <c>false</c>:注册只读的 <see cref="EnvironmentSecretsStore"/>, | ||
| /// 仅从 <see cref="IConfiguration"/>(含 <c>AEVATAR_</c> 环境变量)读取, | ||
| /// <c>Set</c>/<c>Remove</c> 直接抛 <see cref="InvalidOperationException"/>。 | ||
| /// 不创建本地目录。生产/mainnet 入口必须传 <c>false</c>。 | ||
| /// </para> | ||
| /// <para> | ||
| /// <b>前置条件(仅 <c>false</c> 路径)</b>:DI 容器中必须已注册 | ||
| /// <see cref="IConfiguration"/>,<see cref="EnvironmentSecretsStore"/> | ||
| /// 通过构造函数注入它。<see cref="AddAevatarConfig"/> | ||
| /// 自身不注册 <see cref="IConfiguration"/>;通过 | ||
| /// <c>WebApplicationBuilder</c> 或 <c>HostBuilder</c> 调用本扩展时框架已经 | ||
| /// 注册好。裸 <see cref="IServiceCollection"/> 调用方需自行 | ||
| /// <c>services.AddSingleton<IConfiguration>(...)</c>。 | ||
| /// </para> | ||
| /// </param> | ||
| public static IServiceCollection AddAevatarConfig( | ||
| this IServiceCollection services, | ||
| bool allowLocalFileStore = true) | ||
| { | ||
| AevatarPaths.EnsureDirectories(); | ||
| services.TryAddSingleton<IAevatarSecretsStore, AevatarSecretsStore>(); | ||
| services.TryAddSingleton<AevatarSecretsStore>(); | ||
| if (allowLocalFileStore) | ||
| { | ||
| AevatarPaths.EnsureDirectories(); | ||
| services.TryAddSingleton<IAevatarSecretsStore, AevatarSecretsStore>(); | ||
| services.TryAddSingleton<AevatarSecretsStore>(); | ||
| } | ||
| else | ||
| { | ||
| services.TryAddSingleton<IAevatarSecretsStore, EnvironmentSecretsStore>(); | ||
| services.TryAddSingleton<EnvironmentSecretsStore>(); | ||
|
Comment on lines
+54
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| services.TryAddSingleton<ICredentialProvider, SecretsStoreCredentialProvider>(); | ||
| services.TryAddSingleton<SecretsStoreCredentialProvider>(); | ||
| return services; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
AddAevatarBootstrap(..., false)still leavesEnvironmentSecretsStoreunresolvableThe env-only path still depends on callers pre-registering
IConfigurationeven when they use this bootstrap overload, which already receives theconfigurationinstance. In plainServiceCollectionusage,services.AddAevatarBootstrap(configuration, allowLocalFileSecretsStore: false)registersEnvironmentSecretsStore, but resolvingIAevatarSecretsStorelater fails because DI has noIConfiguration. The new test masks this by callingservices.AddSingleton(configuration)before the bootstrap call; production callers of this overload should not need that extra undocumented step. Please register the passed configuration here, e.g.services.TryAddSingleton<IConfiguration>(configuration), beforeAddAevatarConfig(false).