notify: SPA 管理凭据改用 CONFIG_ENCRYPTION_KEY,解除与 Redis ACL 密码耦合

原实现把 SPA 管理凭据派生自 REDIS_URL 的 ACL 密码,并在 handler 侧用
`.is_empty()` 直接拒绝空凭据。托管 Redis 常见配置是 TLS-only 无密码,
那种情况下 `.is_empty()` 恒为真,管理界面永久 401 —— `POST /api/bootstrap`
与 `POST /api/admin/session` 双双不可用,无法完成初始化。

改为直接取 CONFIG_ENCRYPTION_KEY:它本就是启动必填 Secret(64 位 hex /
32 字节,见 config::encryption_key_from_env),启动时已校验,因此生产环境
不可能出现空凭据,handler 侧的空串守卫随之失效,一并移除。Redis ACL 密码
回归本职:只用于数据库连接。

- main.rs:env 只读一次(encryption_key_raw),admin_token 与已校验值不可能
  分叉;删除 Url::parse(redis_url).password() 分支。
- notify.rs:AppState 字段 bootstrap_token → admin_token(4 个构造点同步),
  两处 handler 删除空串守卫,bootstrap() 文档不再称 Redis ACL bootstrap。
- 测试更名 bootstrap_rejects_missing_admin_credential:断言语义不变(测试态
  空凭据收到 Bearer 仍 401),名称改为反映新语义。

门禁全绿:cargo fmt --check / check --locked / clippy --all-targets -D warnings
/ test --locked = 45 passed, 0 failed, 1 ignored。
This commit is contained in:
anonsaber 2026-09-28 12:43:33 +08:00
parent 82794c95cf
commit 800d6e0d00
2 changed files with 19 additions and 20 deletions

View File

@ -43,6 +43,9 @@ async fn main() -> Result<(), error::BotError> {
String::new()
}
};
// The SPA admin credential is this same Secret (see below): capture it once so the
// validated value and the admin token can never diverge.
let encryption_key_raw = std::env::var("CONFIG_ENCRYPTION_KEY").unwrap_or_default();
let encryption_key = match encryption_key_from_env() {
Ok(key) => key,
Err(_) => {
@ -84,11 +87,11 @@ async fn main() -> Result<(), error::BotError> {
let redis =
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?;
let bootstrap_token = url::Url::parse(redis_url.expose_secret())
.ok()
.and_then(|value| value.password().map(ToOwned::to_owned))
.map(secrecy::SecretString::new)
.unwrap_or_else(|| secrecy::SecretString::new(String::new()));
// SPA admin credential == CONFIG_ENCRYPTION_KEY (validated as a required
// startup Secret above). It must not be derived from REDIS_URL: a Redis ACL
// password only gates the database connection, and passwordless managed
// Redis would leave the admin UI permanently locked out.
let admin_token = secrecy::SecretString::new(encryption_key_raw);
let worker_state: std::sync::Arc<dyn state::ReliableState> = std::sync::Arc::new(
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?,
@ -172,7 +175,7 @@ async fn main() -> Result<(), error::BotError> {
allowlist,
Arc::new(WorkerHandle::new(worker)),
runtime,
bootstrap_token,
admin_token,
)
.merge(web::router());
axum::serve(listener, app).await?;

View File

@ -43,7 +43,7 @@ struct AppState {
allowlist: Arc<HashSet<i64>>,
worker: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider,
bootstrap_token: SecretString,
admin_token: SecretString,
business_config: Arc<RwLock<Option<serde_json::Value>>>,
business_runtime: Arc<RwLock<Option<BusinessConfig>>>,
business_revision: Arc<RwLock<u64>>,
@ -595,12 +595,10 @@ async fn put_business_config(
StatusCode::NO_CONTENT.into_response()
}
/// One-shot Redis ACL bootstrap. The ACL password is only compared in constant time and is
/// never included in the response; SET-NX in ReliableState closes the initialization race.
/// One-shot business-config bootstrap. The admin credential is only compared in constant time
/// and is never included in the response; SET-NX in ReliableState closes the init race.
async fn bootstrap(State(app): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
if app.bootstrap_token.expose_secret().is_empty()
|| !worker_authorized(&headers, app.bootstrap_token.expose_secret())
{
if !worker_authorized(&headers, app.admin_token.expose_secret()) {
return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false);
}
let Ok(config) = serde_json::from_slice::<serde_json::Value>(&body) else {
@ -759,9 +757,7 @@ async fn revoke_admin_session(State(app): State<AppState>, headers: HeaderMap) -
}
async fn create_admin_session(State(app): State<AppState>, headers: HeaderMap) -> Response {
if app.bootstrap_token.expose_secret().is_empty()
|| !worker_authorized(&headers, app.bootstrap_token.expose_secret())
{
if !worker_authorized(&headers, app.admin_token.expose_secret()) {
return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false);
}
let mut bytes = [0_u8; 32];
@ -1229,7 +1225,7 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider,
bootstrap_token: SecretString,
admin_token: SecretString,
) -> Router {
router_with_worker_state_runtime_bootstrap_config(
secrets,
@ -1238,7 +1234,7 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
allowlist,
worker_handler,
runtime,
bootstrap_token,
admin_token,
Vec::new(),
)
}
@ -1254,7 +1250,7 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
allowlist: HashSet<i64>,
worker_handler: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider,
bootstrap_token: SecretString,
admin_token: SecretString,
setup_missing: Vec<String>,
) -> Router {
let worker_handle = Arc::new(WorkerHandle::new(worker_handler));
@ -1282,7 +1278,7 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
allowlist: Arc::new(allowlist),
worker: worker_handle,
runtime,
bootstrap_token,
admin_token,
business_config: Arc::new(RwLock::new(None)),
business_runtime: Arc::new(RwLock::new(None)),
business_revision: Arc::new(RwLock::new(0)),
@ -1366,7 +1362,7 @@ mod tests {
}
#[tokio::test]
async fn bootstrap_rejects_without_acl_bootstrap_credential() {
async fn bootstrap_rejects_missing_admin_credential() {
assert_eq!(
request(
Request::builder()