diff --git a/AGENTS.md b/AGENTS.md index 9c7297f5a..b5dc66393 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,22 @@ mcp-server -> (独立应用,无内部模块依赖) - **模型路由与容错**:Chat / Embedding / Rerank 均为候选模型配置驱动,含优先级、失败阈值、熔断恢复;供应商差异留在 infra-ai - **入库管线**:文档入库为 `IngestionNode` 节点编排 Pipeline(解析→增强→分块→向量化→写库) +## 配置与凭据 + +application.yaml 携带本地开发默认凭据,生产部署必须覆盖。`ProductionCredentialGuard`(`bootstrap` 模块,`EnvironmentPostProcessor`,Boot 3 机制注册)在启动最前置阶段执行 fail-fast: + +- **放行条件**:未显式激活任何 profile,或激活 profile 集合含 `local` / `dev` / `test` 任一。本地直接启动(无 profile)行为保持不变。 +- **检查时机**:其他 profile(如 `prod`)下,对以下敏感键检查生效值,命中开发默认值即抛 `IllegalStateException` 中断启动(消息只含键名,不含值): + - `spring.datasource.username` + - `spring.datasource.password` + - `spring.data.redis.password` + - `rag.storage.s3.access-key` + - `rag.storage.s3.secret-key` +- **占位符检查**:值以 `${` 开头且无默认值时(如 `${DB_PASSWORD}`),若环境变量/secret 无法解析同样中断启动;`${KEY:默认值}` 的默认值命中开发默认凭据集合也会中断。 +- **模型 API key 走环境变量属正向机制**(`BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`、`AIHUBMIX_API_KEY`、`MINERU_API_KEY`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`、`YDC_API_KEY` 等),不参与守卫检查,但生产必须提供。 + +生产部署的完整键清单与示例见 `docs/production-configuration.md`。密钥轮换属部署侧运维,守卫不参与;轮换时保证新值先注入、旧值下线,避免空窗期。 + ## 扩展点 按 Spring Bean 自动发现,新增能力优先走扩展点而非改核心分发逻辑: diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java new file mode 100644 index 000000000..519b3d7aa --- /dev/null +++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nageoffer.ai.ragent.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +/** + * 生产 profile 下的开发默认凭据 fail-fast 守卫。 + * + *

application.yaml 携带一组本地开发默认凭据(postgres / 123456 / rustfsadmin 等)。未显式激活 + * profile 或激活集合含 local / dev / test 时完全放行,本地直接启动行为不变;其他 profile(如 prod) + * 下检查敏感键的生效值:命中已知开发默认值、或占位符无默认值且无法解析时抛 + * {@link IllegalStateException} 中断启动,避免弱凭据静默上线。

+ * + *

通过 {@code META-INF/spring.factories} 注册(EnvironmentPostProcessor 在 Spring Boot 3 只从 + * spring.factories 加载,不走 auto-configuration 的 .imports 机制),排到 + * {@link Ordered#LOWEST_PRECEDENCE} 保证晚于 ConfigDataEnvironmentPostProcessor,能读到 + * application.yaml 与 profile 解析结果。

+ * + *

异常消息只含键名与提示,绝不包含配置值。

+ */ +public class ProductionCredentialGuard implements EnvironmentPostProcessor, Ordered { + + private static final Set DEV_PROFILES = Set.of("local", "dev", "test"); + + private static final Map> DEV_DEFAULT_CREDENTIALS = Map.of( + "spring.datasource.username", Set.of("postgres", "root"), + "spring.datasource.password", Set.of("postgres", "123456", "root", "password"), + "spring.data.redis.password", Set.of("123456", "password", "redis"), + "rag.storage.s3.access-key", Set.of("rustfsadmin", "minioadmin"), + "rag.storage.s3.secret-key", Set.of("rustfsadmin", "minioadmin")); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (isDevLaunch(environment)) { + return; + } + DEV_DEFAULT_CREDENTIALS.forEach((key, defaults) -> check(environment, key, defaults)); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private boolean isDevLaunch(ConfigurableEnvironment environment) { + String active = environment.getProperty("spring.profiles.active"); + if (active == null || active.isBlank()) { + return true; + } + return Arrays.stream(active.split(",")) + .map(String::trim) + .filter(profile -> !profile.isEmpty()) + .anyMatch(DEV_PROFILES::contains); + } + + private void check(ConfigurableEnvironment environment, String key, Set devDefaults) { + String value; + try { + value = environment.getProperty(key); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "配置键 [" + key + "] 的占位符未配置且无默认值,请通过环境变量或 secret 覆盖后重启"); + } + if (value != null && devDefaults.contains(value)) { + throw new IllegalStateException( + "配置键 [" + key + "] 检测到开发默认值,请通过环境变量或 secret 覆盖后重启"); + } + } +} diff --git a/bootstrap/src/main/resources/META-INF/spring.factories b/bootstrap/src/main/resources/META-INF/spring.factories index c958685d3..893265065 100644 --- a/bootstrap/src/main/resources/META-INF/spring.factories +++ b/bootstrap/src/main/resources/META-INF/spring.factories @@ -1,6 +1,7 @@ # 检索通道配置一致性校验:启动最前置的环境后置处理器(EnvironmentPostProcessor 仅从 spring.factories 加载,不走 .imports) org.springframework.boot.env.EnvironmentPostProcessor=\ - com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor + com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor,\ + com.nageoffer.ai.ragent.config.ProductionCredentialGuard # 检索通道配置矛盾的启动诊断器(把 RetrievalConfigException 渲染成 APPLICATION FAILED TO START 框) org.springframework.boot.diagnostics.FailureAnalyzer=\ diff --git a/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java b/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java new file mode 100644 index 000000000..1b6d03115 --- /dev/null +++ b/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nageoffer.ai.ragent.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProductionCredentialGuardTest { + + private final ProductionCredentialGuard guard = new ProductionCredentialGuard(); + + @Test + void noProfileAllowsDevDefaults() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.password", "postgres") + .withProperty("spring.data.redis.password", "123456"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } + + @Test + void devProfileAllowsDevDefaults() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "dev") + .withProperty("spring.datasource.password", "postgres"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } + + @Test + void localProfileAllowsDevDefaults() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "local") + .withProperty("rag.storage.s3.secret-key", "rustfsadmin"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } + + @Test + void prodProfileWithDevCredentialFails() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("spring.datasource.password", "postgres"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> guard.postProcessEnvironment(environment, null)); + assertTrue(ex.getMessage().contains("spring.datasource.password")); + assertFalse(ex.getMessage().contains("postgres")); + } + + @Test + void prodProfileWithEnvOverridePasses() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("spring.datasource.password", "S3cret-Override!"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } + + @Test + void prodProfileWithUnresolvablePlaceholderFails() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("spring.datasource.password", "${DB_PASSWORD}"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> guard.postProcessEnvironment(environment, null)); + assertTrue(ex.getMessage().contains("spring.datasource.password")); + assertFalse(ex.getMessage().contains("DB_PASSWORD")); + } + + @Test + void prodProfileWithResolvablePlaceholderPasses() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("DB_PASSWORD", "S3cret-Override!") + .withProperty("spring.datasource.password", "${DB_PASSWORD}"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } + + @Test + void prodProfileWithWeakPlaceholderDefaultFails() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("spring.datasource.password", "${DB_PASSWORD:postgres}"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> guard.postProcessEnvironment(environment, null)); + assertTrue(ex.getMessage().contains("spring.datasource.password")); + assertFalse(ex.getMessage().contains("postgres")); + } + + @Test + void prodProfileWithEmptyPlaceholderDefaultPasses() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.profiles.active", "prod") + .withProperty("spring.datasource.password", "${DB_PASSWORD:}"); + assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null)); + } +} diff --git a/docs/production-configuration.md b/docs/production-configuration.md new file mode 100644 index 000000000..436fd05f5 --- /dev/null +++ b/docs/production-configuration.md @@ -0,0 +1,64 @@ +# 生产部署配置说明 + +本文档说明 fork 在生产 profile 下的凭据覆盖要求与 fail-fast 守卫行为。所有示例均使用占位符,不包含真实凭据。 + +## 1. 配置来源与开发默认值 + +`bootstrap/src/main/resources/application.yaml` 为本地开发默认配置,包含一组开发默认凭据(本地数据库口令、对象存储访问密钥等)。这些值仅用于本地起服务,生产环境必须覆盖。 + +模型供应商 API key(百炼、SiliconFlow、AIHubMix、MinerU、OSS、You.com 等)一律通过环境变量注入(如 `BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`),yaml 中只保留 `${XXX_API_KEY:}` 占位符,属正向机制,不参与守卫检查。 + +## 2. Fail-fast 守卫机制 + +`ProductionCredentialGuard`(`com.nageoffer.ai.ragent.config`)是 Spring Boot 3 `EnvironmentPostProcessor`,通过 `bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor` 注册,在应用启动最前置阶段(早于任何 bean 与连接建立)执行: + +| 场景 | 行为 | +|:---|:---| +| 未激活任何 profile | 放行(本地直接启动体验不变) | +| 激活集合含 `local` / `dev` / `test` 任一 | 放行 | +| 其他 profile(如 `prod`)且生效值命中开发默认凭据 | 启动失败 | +| 其他 profile 且占位符无默认值、无法从环境变量解析 | 启动失败 | +| 其他 profile 且 `${KEY:默认值}` 的默认值命中开发默认凭据 | 启动失败 | + +失败消息只含配置键名与提示("请通过环境变量或 secret 覆盖"),不输出任何配置值。 + +## 3. 生产必须覆盖的键 + +启动 profile 为生产(非 local/dev/test)时,以下键的生效值不得命中开发默认凭据集合: + +| 键 | 环境变量注入方式(示例) | +|:---|:---| +| `spring.datasource.username` | `SPRING_DATASOURCE_USERNAME` | +| `spring.datasource.password` | `SPRING_DATASOURCE_PASSWORD` | +| `spring.data.redis.password` | `SPRING_DATA_REDIS_PASSWORD` | +| `rag.storage.s3.access-key` | `RAG_STORAGE_S3_ACCESS_KEY` | +| `rag.storage.s3.secret-key` | `RAG_STORAGE_S3_SECRET_KEY` | + +Spring Boot 的宽松绑定会把环境变量名映射到对应键;也可以使用 `--spring.datasource.password=...` 命令行参数或外部化配置文件。 + +生产还应根据部署形态覆盖非凭据项(不触发守卫,但影响正确性):数据源 URL、Redis host/port、S3 endpoint、`rag.vector.type`、`rag.graph.type` 等。 + +## 4. 启动示例 + +```bash +# 生产 profile 启动(凭据来自环境变量,示例占位符) +export SPRING_PROFILES_ACTIVE=prod +export SPRING_DATASOURCE_USERNAME='' +export SPRING_DATASOURCE_PASSWORD='' +export SPRING_DATA_REDIS_PASSWORD='' +export RAG_STORAGE_S3_ACCESS_KEY='' +export RAG_STORAGE_S3_SECRET_KEY='' +export BAILIAN_API_KEY='' +export SILICONFLOW_API_KEY='' +./mvnw -B -ntp -pl bootstrap spring-boot:run +``` + +若任一敏感键仍为开发默认值,启动会立即失败并提示对应键名,不会带弱凭据上线。 + +## 5. 密钥轮换提醒 + +守卫只在启动时检查,不参与运行期密钥轮换: + +- 轮换采用"先注入新值、验证生效、再下线旧值"的顺序,避免空窗期; +- 环境变量/secret 变更后需重启应用(或在支持动态刷新的场景单独处理); +- 数据库、Redis、对象存储的凭据轮换建议与各自平台的密钥管理策略配合(Vault、KMS 等),本项目不做配置加密与密钥托管。