收敛后端依赖装配与鉴权并完善异步追踪 (#425)
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
HTTP 请求取消后,在途计数原先无法释放;项目元数据和 External API 鉴权依赖完整 AppState,相同鉴权和追踪配置也散落在多个入口。本次集中装配这些依赖和横切能力,保持现有公开 API、权限、计费、幂等和事务规则。 ## 修改 - 91 个受保护路由集中应用鉴权;保留方法级 404/405/HEAD/Allow、公开入口、MCP、精选缓存头及 2/4 MiB 请求限制。 - 七个项目元数据入口改用缓存的 EditorProjectState,External/MCP 鉴权改用 ExternalApiAuthState;生产实现复用 SpacetimeClient,媒体修复维持原有上传和登记顺序。 - RAII 覆盖请求 Future 取消和 panic unwind 的计数清理;正常与降级服务复用 TraceLayer,指标采用 MatchedPath 模板及固定兜底。 - LLM 普通与流式调用增加跳过参数的异步 span,保持父上下文、流式回调、错误与重试行为;补充替代依赖测试并同步锁文件和文档。 ## 验证 | 验证面 | 结果 | | --- | --- | | platform-llm 完整本地回归 | 161 个单元测试、3 个集成测试通过;1 个真实 Provider 用例按原配置忽略 | | api-server 完整回归 | 执行时 1075 通过、12 失败、6 忽略;其中 1 个新增公开读取 fixture 断言已修正,14 个路由契约回归随后全部通过;剩余 11 个是下述既有 Windows 失败 | | 窄依赖、取消与追踪 | 元数据 owner/幂等/revision、鉴权及 MCP 错误传播、取消/panic/流式响应、追踪父子关系与敏感参数省略均通过 | | 实际本地服务 | 独立 SpacetimeDB 上 102/102 检查通过,两个动态项目 ID 的路由模板及请求 ID 日志核验 3/3 通过 | | 编译与边界 | api-server cargo check、AGC 锁文件下 platform-llm cargo check、rustfmt、编码、文档索引、DDD 与 diff 检查通过 | | 合入最新 master 后 | 后端源码及锁文件保持已测内容;再次通过 14 个路由契约测试、3 个 Provider 追踪测试及编码/文档/DDD/diff 检查 | 实际服务检查覆盖 health/ready、两账号登录、项目 CRUD、幂等重复、跨 owner 拒绝、revision 冲突、External/MCP 读取、Key 撤销及 404/405。使用既有 test 环境的本地 Router 拒绝 fixture,未调用真实付费 Provider;自建服务已关闭,原开发实例保留。 ## 已知测试限制 API 全量测试尚未全绿:11 个 wallet_refund_outbox 用例在 Windows 的目录同步处失败。其生产文件与变更前内容一致;标准库隔离复现确认 File::open(目录) 返回 OS 5,而普通文件写入、同步及 hard_link 正常。这个已有的目录持久化问题未混入本次重构,也未通过跳过或弱化相关断言掩盖。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/425
This commit was merged in pull request #425.
This commit is contained in:
@@ -16,7 +16,7 @@ use crate::{
|
||||
external_api_keys::{hash_external_api_key, map_external_api_key_error},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
state::ExternalApiAuthState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -50,7 +50,7 @@ impl ExternalApiPrincipal {
|
||||
}
|
||||
|
||||
pub async fn require_external_api_key(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<ExternalApiAuthState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -61,7 +61,7 @@ pub async fn require_external_api_key(
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let raw_key = extract_external_api_bearer(request.headers())?;
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.authenticator()
|
||||
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
|
||||
key_hash: hash_external_api_key(raw_key.as_str()),
|
||||
used_at_micros: current_utc_micros(),
|
||||
@@ -88,7 +88,7 @@ pub async fn require_external_api_key(
|
||||
}
|
||||
|
||||
pub async fn require_external_mcp_api_key(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<ExternalApiAuthState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -171,6 +171,184 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::external_api_auth::ExternalApiKeyAuthenticator;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::Extension,
|
||||
middleware,
|
||||
routing::get,
|
||||
};
|
||||
use futures_util::future::BoxFuture;
|
||||
use spacetime_client::{ExternalApiKeyRecord, SpacetimeClientError};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct RecordingAuthenticator {
|
||||
requests: Mutex<Vec<ExternalApiKeyAuthenticateRecordInput>>,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ExternalApiKeyAuthenticator for RecordingAuthenticator {
|
||||
fn authenticate_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
|
||||
self.requests.lock().unwrap().push(input);
|
||||
Box::pin(async move {
|
||||
if let Some(error) = self.error {
|
||||
return Err(SpacetimeClientError::Procedure(error.to_string()));
|
||||
}
|
||||
Ok(ExternalApiKeyRecord {
|
||||
key_id: "key-from-store".to_string(),
|
||||
owner_user_id: "owner-from-store".to_string(),
|
||||
name: "测试密钥".to_string(),
|
||||
key_prefix: "tnr_sk_fixture".to_string(),
|
||||
scopes: vec!["editor:project".to_string()],
|
||||
created_at: "0.000000Z".to_string(),
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
updated_at: "0.000000Z".to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_test_router(
|
||||
dependency: Arc<RecordingAuthenticator>,
|
||||
entered: Arc<AtomicUsize>,
|
||||
mcp: bool,
|
||||
) -> Router {
|
||||
let state = ExternalApiAuthState::new(dependency);
|
||||
let router = Router::new().route("/protected", get(move |Extension(principal): Extension<ExternalApiPrincipal>| {
|
||||
let entered = entered.clone();
|
||||
async move {
|
||||
entered.fetch_add(1, Ordering::Relaxed);
|
||||
axum::Json(json!({"owner": principal.owner_user_id(), "projectScope": principal.has_scope("editor:project")}))
|
||||
}
|
||||
}));
|
||||
if mcp {
|
||||
router.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_external_mcp_api_key,
|
||||
))
|
||||
} else {
|
||||
router.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_external_api_key,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_forwards_store_identity_and_only_hashes_credentials() {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: None,
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.header(AUTHORIZATION, "Bearer tnr_sk_fixture_secret")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let principal = response.extensions().get::<ExternalApiPrincipal>().unwrap();
|
||||
assert_eq!(principal.owner_user_id(), "owner-from-store");
|
||||
assert_eq!(principal.key_id(), "key-from-store");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 1024).await.unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"owner": "owner-from-store", "projectScope": true})
|
||||
);
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 1);
|
||||
let requests = dependency.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].key_hash,
|
||||
hash_external_api_key("tnr_sk_fixture_secret")
|
||||
);
|
||||
assert!(requests[0].used_at_micros > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_preserves_failure_mapping_and_mcp_guide() {
|
||||
for (message, expected_status) in [
|
||||
("API Key 不存在", StatusCode::UNAUTHORIZED),
|
||||
("无权使用此密钥", StatusCode::FORBIDDEN),
|
||||
("校验请求失败", StatusCode::BAD_REQUEST),
|
||||
] {
|
||||
for mcp in [false, true] {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: Some(message),
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), mcp)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.header(AUTHORIZATION, "Bearer invalid-fixture-key")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected_status);
|
||||
assert!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<ExternalApiPrincipal>()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(dependency.requests.lock().unwrap().len(), 1);
|
||||
let body = String::from_utf8(
|
||||
to_bytes(response.into_body(), 16_384)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
if mcp && expected_status == StatusCode::UNAUTHORIZED {
|
||||
assert!(body.contains("MCP_AUTHENTICATION_REQUIRED"));
|
||||
assert!(!body.contains(message));
|
||||
} else {
|
||||
assert!(body.contains(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_rejects_missing_credentials_before_store_access() {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: None,
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(dependency.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() {
|
||||
|
||||
Reference in New Issue
Block a user