修复AGC资产读取埋点用户归属
资产读取路由保留可选鉴权并传递已验证 Bearer 主体到响应扩展 补充资产读取主体传递与匿名边界测试 同步 Issue225 技术方案和项目决策记录
This commit is contained in:
@@ -20,9 +20,10 @@
|
||||
- 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。
|
||||
- 决策:密码登录和手机号登录 handler 在认证及 session 创建成功后,仅对合法 AGC marker 请求向响应 extensions 附加一次性 `TrackingLoginSubject`。tracking middleware 在现有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 之后使用该主体生成登录 route event 的 `user_id`、`owner_user_id` 和 User scope。`daily_login` 保持原有 event key、幂等键、业务日和 metadata 语义,不承担 AGC 来源归因。
|
||||
- 安全边界:主体只来自后端认证服务返回的用户 ID;Header 仅决定是否进行 AGC 来源归因,不参与身份计算;不解析、不记录 access token、refresh token 或 Cookie。
|
||||
- 影响范围:`api-server` 登录 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。
|
||||
- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;tracking identity 单测、`cargo check --locked -p api-server`、`cargo test --locked -p api-server agc_`、格式、编码和 diff 检查通过。
|
||||
- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`。
|
||||
- 影响范围:`api-server` 登录 handler、资产读取 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。
|
||||
- 资产读取边界:`/api/assets/read-url` 和 `/api/assets/read-bytes` 继续支持匿名公开读取;有效 Bearer 复用同一次可选鉴权结果,在成功响应 extensions 中传递 `AuthenticatedAccessToken`,供 AGC route tracking 归属用户。External API Key/Admin 路由继续使用各自主体和审计链路。
|
||||
- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;资产读取主体保留、响应 extension 与匿名不附加单测;tracking identity 单测、`cargo check --locked -p api-server`、相关 `cargo test --locked -p api-server`、格式、编码和 diff 检查通过。
|
||||
- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/api-server/src/assets.rs`。
|
||||
|
||||
## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定
|
||||
|
||||
|
||||
@@ -166,6 +166,21 @@ ExternalApiPrincipal
|
||||
|
||||
本方案不把手机号登录改造成 AGC-only,也不扩大 #225 的 route coverage。
|
||||
|
||||
### 6.4 可选鉴权的资产读取路由
|
||||
|
||||
`/api/assets/read-url` 和 `/api/assets/read-bytes` 同时支持匿名公开素材读取与已登录用户读取,
|
||||
因此不能直接挂接 `require_bearer_auth`。这两个 handler 会继续使用
|
||||
`optional_access_token_from_headers` 完成 Bearer 校验和素材权限判断;如果校验成功,
|
||||
则把同一个已验证的 `AuthenticatedAccessToken` 放入成功响应 extensions,供外层 tracking
|
||||
middleware 读取真实用户主体。
|
||||
|
||||
该传递只发生在当前响应内:
|
||||
|
||||
- 匿名公开读取不附加认证主体,仍按 `anonymous` 记录;
|
||||
- 无效 Bearer 仍返回 `401`,不产生成功 route event;
|
||||
- External API Key 和管理员资产读取继续使用各自的 `ExternalApiPrincipal` / 管理员审计链路;
|
||||
- 不把 Authorization Header、access token、refresh token 或 Cookie 写入埋点。
|
||||
|
||||
## 7. 事件结果示例
|
||||
|
||||
### 7.1 AGC 密码登录成功
|
||||
@@ -244,6 +259,13 @@ HTTP request
|
||||
- `user_id`、`owner_user_id`、`scope_id` 为真实用户;
|
||||
- outbox metadata 不包含 access token、refresh token、Cookie、密码或验证码。
|
||||
|
||||
资产读取的成功链路还必须满足:
|
||||
|
||||
- AGC + 有效 Bearer 的 `/api/assets/read-url` 和 `/api/assets/read-bytes`,route event 的
|
||||
`user_id`、`owner_user_id`、`scope_id` 均为 Bearer 对应的真实用户;
|
||||
- AGC + 无 Bearer 的公开素材读取仍保持匿名归属;
|
||||
- 资产读取权限判断和 tracking 主体传递复用同一次 Bearer 校验结果,不重复解析或自行构造身份。
|
||||
|
||||
### 9.2 未标记回归
|
||||
|
||||
- 未标记手机号登录继续保持既有 route tracking 语义;
|
||||
@@ -299,7 +321,8 @@ CI 已覆盖的全量测试不要求本地重复运行。
|
||||
- `server-rs/crates/api-server/src/tracking.rs` 增加 `TrackingLoginSubject`,并将其作为登录主体的最后兜底来源;既有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 优先级保持不变。
|
||||
- `server-rs/crates/api-server/src/password_entry.rs` 和 `server-rs/crates/api-server/src/phone_auth.rs` 在认证与 session 创建成功后,仅对合法 AGC marker 请求向 response extensions 附加真实用户主体。
|
||||
- `server-rs/crates/api-server/src/app.rs` 只在合法 AGC marker 存在时读取该主体并交给 route tracking;未标记请求不使用该新主体来源。
|
||||
- `server-rs/crates/api-server/src/assets.rs` 保留两个资产读取路由的可选鉴权语义,并在有效 Bearer 成功读取时把已验证主体放入响应 extensions,补齐 AGC 资产读取事件的用户归属;匿名读取和 External API Key/Admin 路由边界未改变。
|
||||
- `daily_login` helper、event id 幂等、认证响应和 Cookie 语义未修改。
|
||||
- 已增加密码登录、手机号登录和主体解析测试;两条真实 `build_router → 登录 handler → response extension → tracking middleware → outbox` 链路均验证通过。
|
||||
- 已增加密码登录、手机号登录、资产读取主体传递和主体解析测试;登录链路与资产读取主体传递均验证通过。
|
||||
|
||||
本次实现没有修改 SpacetimeDB schema、migration、bindings、OpenAPI 或后台页面,也没有新增事件 key。
|
||||
|
||||
@@ -3,7 +3,7 @@ use axum::{
|
||||
body::Body,
|
||||
extract::{Extension, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::Response,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION,
|
||||
@@ -71,6 +71,12 @@ struct AssetReadTarget {
|
||||
is_legacy_public_path: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AssetReadAuthorizationContext {
|
||||
authorization: AssetReadAuthorization,
|
||||
authenticated: Option<AuthenticatedAccessToken>,
|
||||
}
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -180,15 +186,27 @@ pub async fn get_asset_read_url(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<GetReadUrlQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let authorization = resolve_public_asset_read_authorization(
|
||||
) -> Result<Response, AppError> {
|
||||
let authorization_context = resolve_public_asset_read_authorization(
|
||||
&state,
|
||||
&request_context,
|
||||
headers,
|
||||
"/api/assets/read-url",
|
||||
)
|
||||
.await?;
|
||||
get_asset_read_url_with_query(&state, &request_context, query, authorization).await
|
||||
let response = get_asset_read_url_with_query(
|
||||
&state,
|
||||
&request_context,
|
||||
query,
|
||||
authorization_context.authorization,
|
||||
)
|
||||
.await?
|
||||
.into_response();
|
||||
|
||||
Ok(attach_asset_read_authentication(
|
||||
response,
|
||||
authorization_context.authenticated,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_asset_read_url_with_query(
|
||||
@@ -313,16 +331,20 @@ pub async fn get_asset_read_bytes(
|
||||
})?;
|
||||
|
||||
let target = resolve_asset_read_target(&query)?;
|
||||
let authorization = resolve_public_asset_read_authorization(
|
||||
let authorization_context = resolve_public_asset_read_authorization(
|
||||
&state,
|
||||
&request_context,
|
||||
headers,
|
||||
"/api/assets/read-bytes",
|
||||
)
|
||||
.await?;
|
||||
let access_scope =
|
||||
authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization)
|
||||
.await?;
|
||||
let access_scope = authorize_asset_read_target(
|
||||
&state,
|
||||
oss_client.config_bucket(),
|
||||
&target,
|
||||
&authorization_context.authorization,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
@@ -383,7 +405,7 @@ pub async fn get_asset_read_bytes(
|
||||
return Err(map_asset_read_bytes_too_large());
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "private, max-age=60")
|
||||
@@ -393,7 +415,12 @@ pub async fn get_asset_read_bytes(
|
||||
"provider": "asset-read-bytes",
|
||||
"message": format!("构造资源内容响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(attach_asset_read_authentication(
|
||||
response,
|
||||
authorization_context.authenticated,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_asset_history(
|
||||
@@ -741,7 +768,7 @@ async fn resolve_public_asset_read_authorization(
|
||||
request_context: &RequestContext,
|
||||
headers: HeaderMap,
|
||||
path: &str,
|
||||
) -> Result<AssetReadAuthorization, AppError> {
|
||||
) -> Result<AssetReadAuthorizationContext, AppError> {
|
||||
let authenticated = optional_access_token_from_headers(
|
||||
state,
|
||||
path.to_string(),
|
||||
@@ -749,11 +776,27 @@ async fn resolve_public_asset_read_authorization(
|
||||
request_context.request_id().to_string(),
|
||||
)
|
||||
.await?;
|
||||
Ok(authenticated
|
||||
let authorization = authenticated
|
||||
.as_ref()
|
||||
.map(|authenticated| {
|
||||
AssetReadAuthorization::Owner(authenticated.claims().user_id().to_string())
|
||||
})
|
||||
.unwrap_or(AssetReadAuthorization::Anonymous))
|
||||
.unwrap_or(AssetReadAuthorization::Anonymous);
|
||||
|
||||
Ok(AssetReadAuthorizationContext {
|
||||
authorization,
|
||||
authenticated,
|
||||
})
|
||||
}
|
||||
|
||||
fn attach_asset_read_authentication(
|
||||
mut response: Response,
|
||||
authenticated: Option<AuthenticatedAccessToken>,
|
||||
) -> Response {
|
||||
if let Some(authenticated) = authenticated {
|
||||
response.extensions_mut().insert(authenticated);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
async fn authorize_asset_read_target(
|
||||
@@ -1097,12 +1140,14 @@ mod tests {
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
verify_access_token,
|
||||
};
|
||||
use reqwest::{Method, multipart};
|
||||
use serde_json::{Value, json};
|
||||
@@ -1112,7 +1157,10 @@ mod tests {
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::tracking::TrackingClientMarker;
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
use crate::{
|
||||
app::build_router, auth::AuthenticatedAccessToken, config::AppConfig,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -1174,6 +1222,71 @@ mod tests {
|
||||
assert_eq!(draft.metadata["client"], "agc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_asset_read_response_carries_verified_bearer_for_tracking() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138126", "sess_asset_read_tracking").await;
|
||||
let request_context = RequestContext::new(
|
||||
"request-225-asset-read".to_string(),
|
||||
"GET /api/assets/read-url".to_string(),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.expect("authorization header should build"),
|
||||
);
|
||||
|
||||
let authorization_context = super::resolve_public_asset_read_authorization(
|
||||
&state,
|
||||
&request_context,
|
||||
headers,
|
||||
"/api/assets/read-url",
|
||||
)
|
||||
.await
|
||||
.expect("valid bearer should authenticate");
|
||||
let expected_user_id = verify_access_token(&token, state.auth_jwt_config())
|
||||
.expect("test bearer should verify")
|
||||
.user_id()
|
||||
.to_string();
|
||||
assert!(matches!(
|
||||
authorization_context.authorization,
|
||||
super::AssetReadAuthorization::Owner(ref user_id) if user_id == &expected_user_id
|
||||
));
|
||||
|
||||
let authenticated = authorization_context
|
||||
.authenticated
|
||||
.as_ref()
|
||||
.expect("verified bearer should be retained for tracking");
|
||||
assert_eq!(authenticated.claims().user_id(), expected_user_id);
|
||||
|
||||
let response = super::attach_asset_read_authentication(
|
||||
StatusCode::OK.into_response(),
|
||||
authorization_context.authenticated,
|
||||
);
|
||||
let response_authenticated = response
|
||||
.extensions()
|
||||
.get::<AuthenticatedAccessToken>()
|
||||
.expect("asset read response should expose verified bearer to tracking");
|
||||
assert_eq!(response_authenticated.claims().user_id(), expected_user_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_asset_read_response_does_not_add_authentication_extension() {
|
||||
let response =
|
||||
super::attach_asset_read_authentication(StatusCode::OK.into_response(), None);
|
||||
|
||||
assert!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<AuthenticatedAccessToken>()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
fn asset_object_record(
|
||||
access_policy: module_assets::AssetObjectAccessPolicy,
|
||||
owner_user_id: Option<&str>,
|
||||
|
||||
Reference in New Issue
Block a user