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:
parent
82794c95cf
commit
800d6e0d00
15
src/main.rs
15
src/main.rs
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue