mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 12:09:50 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
997 lines
43 KiB
Rust
997 lines
43 KiB
Rust
//! Responses WebSocket 两侧的 PII 脱敏:请求侧 mask + 响应侧 restore。
|
||
//!
|
||
//! HTTP 路径在前门建 `RedactionSessionSlot` 并塞进 `parts.extensions`,planner
|
||
//! 只有拿到这个 slot 才会脱敏。WS 的 planning Parts 是合成的:四个规划入口
|
||
//! (首轮、换模型 re-plan、独立轮、配额透明重试)靠 `build_planning_parts` 注入
|
||
//! slot 就能复用 planner 的脱敏;但复用已绑定 upstream 的 continuation 根本不进
|
||
//! planner,必须在这里先把客户端事件脱敏,再交给协议归一化、上游发送和审计。
|
||
//!
|
||
//! 因此约定:**进入任何下游用途之前,客户端 `response.create` 只在这里脱敏一次**,
|
||
//! 之后所有路径都只看脱敏后的事件。
|
||
//!
|
||
//! # 响应侧
|
||
//!
|
||
//! 只 mask 不 restore 是半个实现:HTTP 在把响应交给客户端之前会把占位符换回真实值
|
||
//! (`privacy::restore_sync_response_body` / `privacy::StreamingResponseRestorer`),
|
||
//! WS 少了这一步,客户端就会直接看到 `<AETHER:EMAIL:...>`。
|
||
//! [`ResponsesWebSocketRedactionRestorer`] 补上这一跳,语义与 HTTP 完全一致:
|
||
//! 复用 `privacy::restore_json_strings_with_budget`,只还原本连接自己 mask 出来的映射,
|
||
//! 未映射的占位符原样透传。
|
||
//!
|
||
//! ## session 为什么活在连接上而不是活在这一轮里
|
||
//!
|
||
//! mask session 由 planner 写进 per-turn 的 slot,而 slot 随 planning Parts 在
|
||
//! 规划结束时就被丢弃,响应帧到达时已经无处可取。可选的存活范围有两个:
|
||
//!
|
||
//! * 挂在 `LogicalTurn` 上:这一轮结束即释放,是 HTTP「一个请求一个 session」的
|
||
//! 直译。但 WS 的会话历史留在上游:continuation 只发增量输入,第 1 轮的
|
||
//! `input` 不会在第 3 轮重发。于是第 3 轮的响应里若回显了第 1 轮的占位符
|
||
//! ("你刚才给我的邮箱是……"),本轮 session 里没有这条映射,占位符就漏给客户端。
|
||
//! HTTP 不会漏,是因为它每次都重发整段历史,重新 mask 同一个值会派生出同一个
|
||
//! sentinel(HMAC over 规则 + bucket + 值),所以映射天然齐备。
|
||
//! * 挂在当前 response chain 上(当前实现):每轮仍然各自 mask、各自持有独立 session
|
||
//! (per-turn 语义不变),连接只是把最近若干轮的 session 留下来一起参与还原,
|
||
//! 凑出的映射集合正好等于「等价 HTTP 请求会拥有的那一份」。
|
||
//!
|
||
//! 选后者。省略(或置空)`previous_response_id` 会开始一条独立 response chain,
|
||
//! 此时必须丢弃旧链的映射,否则新链里偶然出现的旧 sentinel 会被还原成旧链 PII。
|
||
//! 代价是每帧最多对 [`MAX_RETAINED_TURN_REDACTION_SESSIONS`] 个 session 各扫一遍,
|
||
//! 以及这些 session 的映射会驻留到当前链结束;用有界 FIFO 兜住上限。
|
||
//! 窗口不够用或每帧成本变高时,正确的下一步是在 `privacy` 侧提供跨 session 的
|
||
//! 合并匹配器,而不是把这个窗口调大。
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use serde_json::Value;
|
||
|
||
use crate::ai_serving::{
|
||
resolve_local_decision_execution_runtime_auth_context, resolve_provider_chat_pii_redaction,
|
||
};
|
||
use crate::control::GatewayControlDecision;
|
||
use crate::privacy::{
|
||
restore_json_strings_with_budget, serialize_json_value_with_limit, RedactionSession,
|
||
RedactionSessionSlot, RestoreExpansionBudget, MAX_STREAM_RESTORE_EXPANSION_BYTES,
|
||
MAX_STREAM_RESTORE_OUTPUT_BYTES,
|
||
};
|
||
use crate::{AppState, GatewayError};
|
||
|
||
/// Responses WebSocket 只承载 `openai:responses`,脱敏规则按这个客户端格式选取。
|
||
const RESPONSES_WEBSOCKET_CLIENT_API_FORMAT: &str = "openai:responses";
|
||
|
||
/// WS 在选出候选之前就要脱敏,所以脱敏 session 先记在这个固定 key 下。
|
||
///
|
||
/// slot 是 per-turn 的(见 `build_planning_parts`),这一轮之后即随 slot 一起丢弃;
|
||
/// planner 后续用真实 candidate_id 再取一次配置时,body 已是脱敏态、不会重复写入。
|
||
const WEBSOCKET_TURN_REDACTION_CANDIDATE_ID: &str = "responses_websocket_turn";
|
||
|
||
/// 一条连接最多留几轮的 mask session 用于响应侧还原。
|
||
///
|
||
/// 取值权衡见模块文档:调大会线性增加每帧还原成本和常驻映射量,调小则更容易漏还原
|
||
/// 上游历史里更早那几轮的占位符。8 覆盖的是「上游最可能回显的最近窗口」。
|
||
const MAX_RETAINED_TURN_REDACTION_SESSIONS: usize = 8;
|
||
|
||
/// 一轮客户端 `response.create` 的请求侧脱敏结果。
|
||
#[derive(Debug)]
|
||
pub(super) struct ResponsesWebSocketTurnRedaction {
|
||
/// 脱敏后的客户端事件;这一轮之后所有下游路径都只看它。
|
||
pub(super) client_event: Value,
|
||
/// 这一轮 mask 出来的映射表,响应侧还原只能靠它。
|
||
pub(super) session: RedactionSession,
|
||
}
|
||
|
||
/// 对一条客户端 `response.create` 做请求侧脱敏。
|
||
///
|
||
/// 返回 `Some(..)` 仅当脱敏真正命中;`None` 表示未启用或没有命中,调用方
|
||
/// 继续用原事件即可(避免未开启脱敏时多一次整包 clone)。
|
||
///
|
||
/// 脱敏只改写 `instructions` / `input`(见 `privacy::mask_openai_responses_request_value`),
|
||
/// `type` / `model` / `previous_response_id` / `generate` 等协议字段原样保留,所以脱敏后的
|
||
/// 事件仍可直接用于协议归一化和上游发送。
|
||
///
|
||
/// 出错必须让这一轮失败:脱敏已启用却读不到配置或加密密钥时,把原文发上游就是
|
||
/// 静默旁路,正是本次要修的问题。
|
||
pub(super) async fn redact_responses_websocket_client_event(
|
||
state: &AppState,
|
||
parts: &http::request::Parts,
|
||
control_decision: &GatewayControlDecision,
|
||
client_event: &Value,
|
||
) -> Result<Option<ResponsesWebSocketTurnRedaction>, GatewayError> {
|
||
redact_responses_websocket_client_event_with_reasoning_replay_policy(
|
||
state,
|
||
parts,
|
||
control_decision,
|
||
client_event,
|
||
crate::ai_serving::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Variant used only after the gateway has selected and authenticated the
|
||
/// provider binding. The replay policy comes from that trusted binding, never
|
||
/// from client JSON, so a forged reasoning-item shape cannot opt itself into
|
||
/// byte-opaque PII handling.
|
||
pub(super) async fn redact_responses_websocket_client_event_with_reasoning_replay_policy(
|
||
state: &AppState,
|
||
parts: &http::request::Parts,
|
||
control_decision: &GatewayControlDecision,
|
||
client_event: &Value,
|
||
reasoning_replay_policy: crate::ai_serving::OpenAiResponsesReasoningReplayPolicy,
|
||
) -> Result<Option<ResponsesWebSocketTurnRedaction>, GatewayError> {
|
||
let Some(auth_context) =
|
||
resolve_local_decision_execution_runtime_auth_context(control_decision)
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let redaction = resolve_provider_chat_pii_redaction(
|
||
state,
|
||
parts,
|
||
client_event,
|
||
&auth_context,
|
||
RESPONSES_WEBSOCKET_CLIENT_API_FORMAT,
|
||
reasoning_replay_policy,
|
||
WEBSOCKET_TURN_REDACTION_CANDIDATE_ID,
|
||
)
|
||
.await?;
|
||
if !redaction.redacted {
|
||
return Ok(None);
|
||
}
|
||
// mask 命中时 `resolve_provider_chat_pii_redaction` 必定把 session 写进 slot。
|
||
// 取不到就是内部契约被破坏了,此时继续下发意味着这一轮的响应无法还原、占位符
|
||
// 会漏给客户端;按本模块既有的「脱敏链路出错就让这一轮失败」处理,不做降级。
|
||
let Some(session) = parts
|
||
.extensions
|
||
.get::<RedactionSessionSlot>()
|
||
.and_then(|slot| slot.take_for_candidate(Some(WEBSOCKET_TURN_REDACTION_CANDIDATE_ID)))
|
||
else {
|
||
return Err(GatewayError::Internal(
|
||
"chat pii redaction masked a Responses WebSocket turn without retaining its session"
|
||
.to_string(),
|
||
));
|
||
};
|
||
Ok(Some(ResponsesWebSocketTurnRedaction {
|
||
client_event: redaction.body_json.into_owned(),
|
||
session,
|
||
}))
|
||
}
|
||
|
||
/// 当前 response chain 上「我们 mask 过哪些映射」的留存集合,供响应侧还原使用。
|
||
///
|
||
/// 每轮一个独立 session(per-turn mask 语义不变),当前链按 FIFO 留最近
|
||
/// [`MAX_RETAINED_TURN_REDACTION_SESSIONS`] 轮。物理上游重绑本身不决定生命周期;
|
||
/// `previous_response_id` 决定是否延续旧链。独立请求成功发出时由调用方通过
|
||
/// [`Self::start_new_chain`] 原子替换为新链的首轮 session。
|
||
#[derive(Default)]
|
||
pub(super) struct ResponsesWebSocketRedactionRestorer {
|
||
sessions: VecDeque<RedactionSession>,
|
||
}
|
||
|
||
impl ResponsesWebSocketRedactionRestorer {
|
||
pub(super) fn has_sessions(&self) -> bool {
|
||
!self.sessions.is_empty()
|
||
}
|
||
|
||
/// 登记这一轮的 mask session。
|
||
pub(super) fn register(&mut self, session: RedactionSession) {
|
||
if session.mapping_count() == 0 {
|
||
return;
|
||
}
|
||
self.sessions.push_back(session);
|
||
while self.sessions.len() > MAX_RETAINED_TURN_REDACTION_SESSIONS {
|
||
self.sessions.pop_front();
|
||
}
|
||
}
|
||
|
||
/// Commits a successfully started independent response chain.
|
||
///
|
||
/// Keep this transition next to the successful upstream send/bind. A
|
||
/// rejected independent request has not replaced the active chain and
|
||
/// therefore must not discard the old chain's restore mappings.
|
||
pub(super) fn start_new_chain(&mut self, session: Option<RedactionSession>) {
|
||
self.sessions.clear();
|
||
if let Some(session) = session {
|
||
self.register(session);
|
||
}
|
||
}
|
||
|
||
/// 把一帧 provider 事件里的占位符换回真实值,返回要发给客户端的帧文本。
|
||
///
|
||
/// `Ok(None)` 表示这一帧没有任何东西要还原,调用方必须原样转发上游字节:
|
||
/// 未启用脱敏(没有任何 session)时连 clone 都不做。`Err` 表示恢复或有界
|
||
/// 序列化失败,调用方必须停止转发,不能把它降级成“未命中”而泄漏占位符。
|
||
///
|
||
/// 入参只读:审计与终态观测继续消费脱敏态的事件,还原只作用于发往客户端的
|
||
/// 那一份拷贝,和 HTTP 侧「审计存脱敏体、线上还原」保持一致。
|
||
pub(super) fn restore_provider_frame_text(
|
||
&self,
|
||
event: &Value,
|
||
) -> Result<Option<String>, GatewayError> {
|
||
if self.sessions.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let mut restored_event = event.clone();
|
||
let mut restored = false;
|
||
// Fresh per provider frame: this bounds one allocation amplification
|
||
// without imposing a cumulative byte or duration cap on the socket.
|
||
let mut budget = RestoreExpansionBudget::new(MAX_STREAM_RESTORE_EXPANSION_BYTES);
|
||
for session in &self.sessions {
|
||
// 逐 session 还原而不是合并映射:每个 session 只认自己 mask 过的
|
||
// sentinel(`RedactionSession::restore_text`),跨 session 合并会绕开
|
||
// 这条边界。同一个值在不同轮派生出的 sentinel 相同,所以顺序无关。
|
||
restored |=
|
||
restore_json_strings_with_budget(&mut restored_event, session, &mut budget)?;
|
||
}
|
||
if !restored {
|
||
return Ok(None);
|
||
}
|
||
// The parsed input frame is bounded at ingress, but use the same bounded
|
||
// serializer here so calculating the restored output limit cannot create
|
||
// an unchecked temporary allocation.
|
||
let original_len =
|
||
serialize_json_value_with_limit(event, MAX_STREAM_RESTORE_OUTPUT_BYTES)?.len();
|
||
let output_limit = original_len
|
||
.checked_add(MAX_STREAM_RESTORE_EXPANSION_BYTES)
|
||
.ok_or_else(|| {
|
||
GatewayError::Internal(
|
||
"WebSocket redaction restored frame length overflow".to_string(),
|
||
)
|
||
})?
|
||
.min(MAX_STREAM_RESTORE_OUTPUT_BYTES);
|
||
serialize_json_value_with_limit(&restored_event, output_limit).map(Some)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::net::SocketAddr;
|
||
use std::sync::Arc;
|
||
|
||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||
use aether_data::repository::auth::{
|
||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord,
|
||
};
|
||
use axum::http::{HeaderMap, Uri};
|
||
use serde_json::{json, Value};
|
||
|
||
use super::super::request::{
|
||
build_planning_parts, normalize_followup_response_create, planned_response_create_event,
|
||
};
|
||
use super::super::turn::prepare_responses_websocket_turn_decision;
|
||
use super::super::turn_state::LogicalTurn;
|
||
use super::{
|
||
redact_responses_websocket_client_event, ResponsesWebSocketRedactionRestorer,
|
||
ResponsesWebSocketTurnRedaction, MAX_RETAINED_TURN_REDACTION_SESSIONS,
|
||
};
|
||
use crate::ai_serving::{AiExecutionDecision, ResponsesWebSocketBodyNormalization};
|
||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||
use crate::handlers::proxy::websocket::ingress::WebSocketRequestContext;
|
||
use crate::AppState;
|
||
|
||
const TEST_USER_ID: &str = "user-responses-ws-redaction";
|
||
const TEST_API_KEY_ID: &str = "api-key-responses-ws-redaction";
|
||
const TEST_EMAIL: &str = "[email protected]";
|
||
/// 另一轮用的 PII,用来证明连接级还原覆盖到更早的轮次。
|
||
const OTHER_TEST_EMAIL: &str = "[email protected]";
|
||
/// 不是本连接 mask 出来的占位符:格式合法(符合 sentinel 正则),但没有任何
|
||
/// session 记过它,必须原样透传。
|
||
const FOREIGN_SENTINEL: &str = "<AETHER:EMAIL:AAAAAAAAAAAAAAAAAAAA>";
|
||
|
||
fn auth_export_record() -> StoredAuthApiKeyExportRecord {
|
||
StoredAuthApiKeyExportRecord::new(
|
||
TEST_USER_ID.to_string(),
|
||
TEST_API_KEY_ID.to_string(),
|
||
"hash-responses-ws-redaction".to_string(),
|
||
None,
|
||
Some("ws".to_string()),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
true,
|
||
None,
|
||
false,
|
||
0,
|
||
0,
|
||
0.0,
|
||
false,
|
||
)
|
||
.expect("auth api key export record should build")
|
||
.with_feature_settings(Some(json!({
|
||
"chat_pii_redaction": {"enabled": true}
|
||
})))
|
||
}
|
||
|
||
/// 只装脱敏真正需要的东西:系统配置开关 + 规则、加密密钥、带 feature settings
|
||
/// 的 API Key 导出记录。候选/上游都不需要,这条链路在 planner 之前。
|
||
fn redaction_enabled_state() -> AppState {
|
||
let auth_repository = Arc::new(
|
||
InMemoryAuthApiKeySnapshotRepository::seed(vec![])
|
||
.with_export_records(vec![auth_export_record()]),
|
||
);
|
||
let data_state =
|
||
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository)
|
||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
||
.with_system_config_values_for_tests(vec![
|
||
("module.chat_pii_redaction.enabled".to_string(), json!(true)),
|
||
(
|
||
"module.chat_pii_redaction.rules".to_string(),
|
||
json!([{
|
||
"id": "email",
|
||
"name": "邮箱",
|
||
"pattern": r"(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}",
|
||
"enabled": true,
|
||
"features": {"validator": "email"},
|
||
"system": true
|
||
}]),
|
||
),
|
||
(
|
||
"module.chat_pii_redaction.cache_ttl_seconds".to_string(),
|
||
json!(300),
|
||
),
|
||
]);
|
||
AppState::new()
|
||
.expect("gateway state should build")
|
||
.with_data_state_for_tests(data_state)
|
||
}
|
||
|
||
fn control_decision() -> GatewayControlDecision {
|
||
let mut decision = GatewayControlDecision::synthetic(
|
||
"/v1/responses".to_string(),
|
||
Some("ai_public".to_string()),
|
||
Some("openai".to_string()),
|
||
Some("responses_websocket".to_string()),
|
||
Some("openai:responses".to_string()),
|
||
);
|
||
decision.auth_context = Some(GatewayControlAuthContext {
|
||
user_id: TEST_USER_ID.to_string(),
|
||
api_key_id: TEST_API_KEY_ID.to_string(),
|
||
username: Some("ws".to_string()),
|
||
api_key_name: Some("ws".to_string()),
|
||
balance_remaining: None,
|
||
access_allowed: true,
|
||
user_rate_limit: None,
|
||
api_key_rate_limit: None,
|
||
api_key_is_standalone: false,
|
||
admin_bypass_limits: false,
|
||
local_rejection: None,
|
||
allowed_models: None,
|
||
ip_rules: None,
|
||
verified_api_key_hash: None,
|
||
});
|
||
decision
|
||
}
|
||
|
||
fn websocket_context(decision: GatewayControlDecision) -> WebSocketRequestContext {
|
||
WebSocketRequestContext {
|
||
trace_id: "trace-responses-ws-redaction".to_string(),
|
||
headers: HeaderMap::new(),
|
||
uri: Uri::from_static("/v1/responses"),
|
||
remote_addr: "127.0.0.1:65000"
|
||
.parse::<SocketAddr>()
|
||
.expect("remote address should parse"),
|
||
client_ip: "127.0.0.1".parse().expect("client IP should parse"),
|
||
decision,
|
||
websocket_connection_permit: None,
|
||
}
|
||
}
|
||
|
||
fn client_event() -> Value {
|
||
client_event_with_email(TEST_EMAIL)
|
||
}
|
||
|
||
fn client_event_with_email(email: &str) -> Value {
|
||
json!({
|
||
"type": "response.create",
|
||
"model": "public-model",
|
||
"previous_response_id": "resp-previous",
|
||
"generate": false,
|
||
"input": [{
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": format!("mail {email}")}]
|
||
}]
|
||
})
|
||
}
|
||
|
||
/// 真跑一遍请求侧脱敏,拿到这一轮的生效事件和 mask session。
|
||
async fn turn_redaction(
|
||
state: &AppState,
|
||
decision: &GatewayControlDecision,
|
||
email: &str,
|
||
) -> ResponsesWebSocketTurnRedaction {
|
||
let context = websocket_context(decision.clone());
|
||
let parts = build_planning_parts(&context);
|
||
let event = client_event_with_email(email);
|
||
redact_responses_websocket_client_event(state, &parts, &context.decision, &event)
|
||
.await
|
||
.expect("redaction should resolve")
|
||
.expect("an email in the request should be redacted")
|
||
}
|
||
|
||
/// 这一轮为 `email` 派生出的占位符。
|
||
fn sentinel_for(redaction: &ResponsesWebSocketTurnRedaction, email: &str) -> String {
|
||
redaction
|
||
.session
|
||
.sentinel_for_original(email)
|
||
.expect("a masked email must have a sentinel")
|
||
.to_string()
|
||
}
|
||
|
||
/// 上游回显占位符的一帧 provider 事件。
|
||
fn provider_delta_frame(text: &str) -> Value {
|
||
json!({
|
||
"type": "response.output_text.delta",
|
||
"item_id": "msg_ws",
|
||
"output_index": 0,
|
||
"content_index": 0,
|
||
"delta": text,
|
||
})
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn websocket_client_event_is_redacted_without_losing_protocol_fields() {
|
||
let state = redaction_enabled_state();
|
||
let context = websocket_context(control_decision());
|
||
let parts = build_planning_parts(&context);
|
||
let event = client_event();
|
||
|
||
let redacted =
|
||
redact_responses_websocket_client_event(&state, &parts, &context.decision, &event)
|
||
.await
|
||
.expect("redaction should resolve")
|
||
.expect("an email in the request should be redacted")
|
||
.client_event;
|
||
|
||
let serialized = serde_json::to_string(&redacted).expect("event should serialize");
|
||
assert!(!serialized.contains(TEST_EMAIL), "{serialized}");
|
||
assert!(serialized.contains("<AETHER:EMAIL:"), "{serialized}");
|
||
// 协议字段必须原样保留,否则 continuation 链路会断。
|
||
assert_eq!(redacted["type"], "response.create");
|
||
assert_eq!(redacted["model"], "public-model");
|
||
assert_eq!(redacted["previous_response_id"], "resp-previous");
|
||
assert_eq!(redacted["generate"], false);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn redacting_an_already_redacted_event_is_a_no_op() {
|
||
// re-plan 与配额重试路径会把已脱敏的事件再交给 planner,planner 内部会对
|
||
// 同一个 body 再跑一遍 mask。占位符本身不该被任何规则命中,否则会被二次
|
||
// 替换、破坏与上游已有 previous_response_id 链的一致性。
|
||
let state = redaction_enabled_state();
|
||
let context = websocket_context(control_decision());
|
||
let parts = build_planning_parts(&context);
|
||
let event = client_event();
|
||
|
||
let redacted =
|
||
redact_responses_websocket_client_event(&state, &parts, &context.decision, &event)
|
||
.await
|
||
.expect("redaction should resolve")
|
||
.expect("an email in the request should be redacted")
|
||
.client_event;
|
||
|
||
// 复用同一个 parts/slot,和 re-plan 在同一 turn 内二次脱敏的情形一致。
|
||
let second_pass =
|
||
redact_responses_websocket_client_event(&state, &parts, &context.decision, &redacted)
|
||
.await
|
||
.expect("second redaction pass should resolve");
|
||
|
||
assert!(
|
||
second_pass.is_none(),
|
||
"already redacted event should stay byte-identical: {second_pass:?}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn redaction_is_skipped_without_a_local_auth_context() {
|
||
let state = redaction_enabled_state();
|
||
let mut decision = control_decision();
|
||
decision.auth_context = None;
|
||
let context = websocket_context(decision);
|
||
let parts = build_planning_parts(&context);
|
||
let event = client_event();
|
||
|
||
let redacted =
|
||
redact_responses_websocket_client_event(&state, &parts, &context.decision, &event)
|
||
.await
|
||
.expect("redaction should resolve");
|
||
|
||
assert!(redacted.is_none());
|
||
}
|
||
|
||
/// 真跑一遍脱敏,拿到这一轮的「生效事件」。
|
||
async fn redacted_client_event(state: &AppState, decision: &GatewayControlDecision) -> Value {
|
||
turn_redaction(state, decision, TEST_EMAIL)
|
||
.await
|
||
.client_event
|
||
}
|
||
|
||
/// 只有 `action` 没有 serde 默认值,其余字段都能省略。
|
||
fn decision_template(
|
||
provider_request_body: Value,
|
||
report_context: Value,
|
||
) -> AiExecutionDecision {
|
||
serde_json::from_value(json!({
|
||
"action": "local",
|
||
"candidate_id": "candidate-responses-ws",
|
||
"provider_request_body": provider_request_body,
|
||
"report_context": report_context,
|
||
}))
|
||
.expect("decision template should deserialize")
|
||
}
|
||
|
||
/// planner 在脱敏 body 上做模型映射后的 provider body。
|
||
fn provider_body_from(effective_event: &Value) -> Value {
|
||
let mut provider_body = effective_event.clone();
|
||
provider_body["model"] = json!("provider-model");
|
||
provider_body
|
||
}
|
||
|
||
/// 绑定那一轮留下的 report_context seed:故意带上原始 PII,用来证明这一轮
|
||
/// 会用脱敏后的 body 覆盖它,而不是把原文带进审计。
|
||
fn seed_report_context_with_raw_pii() -> Value {
|
||
json!({
|
||
"request_id": "connection",
|
||
"candidate_id": "candidate-responses-ws",
|
||
"original_request_body": {
|
||
"type": "response.create",
|
||
"model": "public-model",
|
||
"input": format!("mail {TEST_EMAIL}")
|
||
}
|
||
})
|
||
}
|
||
|
||
fn assert_redacted_json(value: &Value, label: &str) {
|
||
let serialized = serde_json::to_string(value).expect("value should serialize");
|
||
assert!(
|
||
!serialized.contains(TEST_EMAIL),
|
||
"{label} must not carry raw PII: {serialized}"
|
||
);
|
||
assert!(
|
||
serialized.contains("<AETHER:EMAIL:"),
|
||
"{label} must carry the redaction sentinel: {serialized}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn first_turn_upstream_and_audit_bodies_are_redacted() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let effective_event = redacted_client_event(&state, &decision).await;
|
||
let template = decision_template(
|
||
provider_body_from(&effective_event),
|
||
seed_report_context_with_raw_pii(),
|
||
);
|
||
// 首轮实际发上游的事件由 decision.provider_request_body 派生。
|
||
let normalization = ResponsesWebSocketBodyNormalization::for_tests("provider-model");
|
||
let provider_event: Value = serde_json::from_str(
|
||
&planned_response_create_event(&template, &normalization, &effective_event)
|
||
.expect("first provider event should serialize"),
|
||
)
|
||
.expect("first provider event should parse");
|
||
|
||
let turn_decision = prepare_responses_websocket_turn_decision(
|
||
&template,
|
||
"turn-1".to_string(),
|
||
true,
|
||
&effective_event,
|
||
&provider_event,
|
||
"connection",
|
||
1,
|
||
"logical-turn-1",
|
||
1,
|
||
);
|
||
|
||
assert_redacted_json(&provider_event, "first turn upstream event");
|
||
assert_redacted_json(
|
||
turn_decision
|
||
.provider_request_body
|
||
.as_ref()
|
||
.expect("turn decision should carry a provider body"),
|
||
"first turn provider request body",
|
||
);
|
||
let report_context = turn_decision
|
||
.report_context
|
||
.as_ref()
|
||
.expect("turn decision should carry a report context");
|
||
assert_redacted_json(
|
||
&report_context["original_request_body"],
|
||
"first turn audit body",
|
||
);
|
||
// 整个 report_context 都不该残留原文(seed 里的原始 body 必须被覆盖)。
|
||
assert_redacted_json(report_context, "first turn report context");
|
||
assert_eq!(provider_event["type"], "response.create");
|
||
assert_eq!(provider_event["model"], "provider-model");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn continuation_upstream_and_audit_bodies_are_redacted() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let effective_event = redacted_client_event(&state, &decision).await;
|
||
// continuation 复用已绑定的 upstream:不再规划,直接重放归一化器。
|
||
let outbound = normalize_followup_response_create(
|
||
&effective_event,
|
||
"provider-model",
|
||
&ResponsesWebSocketBodyNormalization::for_tests("provider-model"),
|
||
)
|
||
.expect("continuation should normalize");
|
||
let provider_event: Value =
|
||
serde_json::from_str(&outbound).expect("continuation event should parse");
|
||
|
||
let template = decision_template(
|
||
provider_body_from(&effective_event),
|
||
seed_report_context_with_raw_pii(),
|
||
);
|
||
let turn_decision = prepare_responses_websocket_turn_decision(
|
||
&template,
|
||
"turn-2".to_string(),
|
||
false,
|
||
&effective_event,
|
||
&provider_event,
|
||
"connection",
|
||
2,
|
||
"logical-turn-2",
|
||
1,
|
||
);
|
||
|
||
assert!(
|
||
!outbound.contains(TEST_EMAIL),
|
||
"continuation upstream frame must not carry raw PII: {outbound}"
|
||
);
|
||
assert!(
|
||
outbound.contains("<AETHER:EMAIL:"),
|
||
"continuation upstream frame must carry the sentinel: {outbound}"
|
||
);
|
||
assert_eq!(provider_event["previous_response_id"], "resp-previous");
|
||
let report_context = turn_decision
|
||
.report_context
|
||
.as_ref()
|
||
.expect("turn decision should carry a report context");
|
||
assert_redacted_json(
|
||
&report_context["original_request_body"],
|
||
"continuation audit body",
|
||
);
|
||
assert_redacted_json(report_context, "continuation report context");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn quota_retry_replays_the_redacted_event() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let effective_event = redacted_client_event(&state, &decision).await;
|
||
// 配额透明重试重放 LogicalTurn 里保存的事件,所以保存的必须
|
||
// 已经是脱敏版,否则重试会把原文发给新的上游账号。
|
||
let active = LogicalTurn::new(effective_event.clone(), 2, "logical-turn-2".to_string());
|
||
assert_redacted_json(&active.client_event, "quota retry replay event");
|
||
|
||
let template = decision_template(
|
||
provider_body_from(&active.client_event),
|
||
seed_report_context_with_raw_pii(),
|
||
);
|
||
let normalization = ResponsesWebSocketBodyNormalization::for_tests("provider-model");
|
||
let provider_event: Value = serde_json::from_str(
|
||
&planned_response_create_event(&template, &normalization, &active.client_event)
|
||
.expect("retry provider event should serialize"),
|
||
)
|
||
.expect("retry provider event should parse");
|
||
let turn_decision = prepare_responses_websocket_turn_decision(
|
||
&template,
|
||
"turn-2-retry".to_string(),
|
||
true,
|
||
&active.client_event,
|
||
&provider_event,
|
||
"connection",
|
||
active.turn_index,
|
||
"logical-turn-2",
|
||
2,
|
||
);
|
||
|
||
assert_redacted_json(&provider_event, "quota retry upstream event");
|
||
let report_context = turn_decision
|
||
.report_context
|
||
.as_ref()
|
||
.expect("turn decision should carry a report context");
|
||
assert_eq!(report_context["websocket_turn_attempt"], 2);
|
||
assert_redacted_json(
|
||
&report_context["original_request_body"],
|
||
"quota retry audit body",
|
||
);
|
||
assert_redacted_json(report_context, "quota retry report context");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 响应侧还原
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// 本次修复的核心:上游把占位符回显在事件里,客户端必须拿到真实值。
|
||
#[tokio::test]
|
||
async fn provider_frame_placeholders_are_restored_before_client_delivery() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let sentinel = sentinel_for(&redaction, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
let frame = provider_delta_frame(&format!("your mail is {sentinel}"));
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&frame)
|
||
.expect("frame restoration must not fail")
|
||
.expect("a frame echoing this turn's sentinel must be restored");
|
||
|
||
assert!(
|
||
restored.contains(TEST_EMAIL),
|
||
"the client must receive the real value: {restored}"
|
||
);
|
||
assert!(
|
||
!restored.contains(&sentinel),
|
||
"no sentinel may survive to the client: {restored}"
|
||
);
|
||
// 协议字段不受影响,客户端的状态机照旧。
|
||
let restored: Value = serde_json::from_str(&restored).expect("restored frame is JSON");
|
||
assert_eq!(restored["type"], "response.output_text.delta");
|
||
assert_eq!(restored["item_id"], "msg_ws");
|
||
assert_eq!(restored["output_index"], 0);
|
||
}
|
||
|
||
/// Codex 把多个事件批量塞进 `{"chunks":[...]}`,还原必须走进批量里。
|
||
#[tokio::test]
|
||
async fn placeholders_batched_inside_a_chunks_envelope_are_restored() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let sentinel = sentinel_for(&redaction, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
let frame = json!({
|
||
"chunks": [
|
||
provider_delta_frame("plain delta"),
|
||
provider_delta_frame(&format!("mail {sentinel}")),
|
||
]
|
||
});
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&frame)
|
||
.expect("frame restoration must not fail")
|
||
.expect("a batched sentinel must be restored");
|
||
|
||
assert!(restored.contains(TEST_EMAIL), "{restored}");
|
||
assert!(!restored.contains(&sentinel), "{restored}");
|
||
}
|
||
|
||
/// 只还原本连接 mask 过的映射,和 `RedactionSession::restore_text` 一致:
|
||
/// 别处来的占位符(比如客户端自己发的、或上一条连接的)保持原样。
|
||
#[tokio::test]
|
||
async fn an_unmapped_placeholder_is_left_untouched() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let sentinel = sentinel_for(&redaction, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
let frame = provider_delta_frame(&format!("{FOREIGN_SENTINEL} and {sentinel}"));
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&frame)
|
||
.expect("frame restoration must not fail")
|
||
.expect("the mapped sentinel is still restored");
|
||
|
||
assert!(restored.contains(TEST_EMAIL), "{restored}");
|
||
assert!(
|
||
restored.contains(FOREIGN_SENTINEL),
|
||
"an unmapped placeholder must survive verbatim: {restored}"
|
||
);
|
||
}
|
||
|
||
/// 没有命中还原时必须让调用方原样转发上游字节。
|
||
#[tokio::test]
|
||
async fn a_frame_without_known_placeholders_is_not_rewritten() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
assert!(
|
||
restorer
|
||
.restore_provider_frame_text(&provider_delta_frame("nothing to restore"))
|
||
.expect("frame restoration must not fail")
|
||
.is_none(),
|
||
"a frame with no mapped sentinel must be relayed byte-for-byte"
|
||
);
|
||
assert!(
|
||
restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(FOREIGN_SENTINEL))
|
||
.expect("frame restoration must not fail")
|
||
.is_none(),
|
||
"a frame that only carries unmapped placeholders must not be rewritten"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn frame_restore_budget_does_not_accumulate_across_frames() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let sentinel = sentinel_for(&redaction, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
for _ in 0..3 {
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(&sentinel))
|
||
.expect("frame restoration must not fail")
|
||
.expect("each provider frame gets an independent expansion budget");
|
||
assert!(restored.contains(TEST_EMAIL));
|
||
}
|
||
}
|
||
|
||
/// 未启用脱敏(或这条连接从没 mask 到东西)时,还原器必须完全不介入:
|
||
/// 连 clone 都不做,输出就是上游原字节。
|
||
#[tokio::test]
|
||
async fn a_restorer_without_sessions_never_rewrites_a_frame() {
|
||
let restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
|
||
assert!(restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(FOREIGN_SENTINEL))
|
||
.expect("frame restoration must not fail")
|
||
.is_none());
|
||
assert!(restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(TEST_EMAIL))
|
||
.expect("frame restoration must not fail")
|
||
.is_none());
|
||
}
|
||
|
||
/// 空 session(启用了脱敏但这一轮没命中任何规则)不该被留下来白扫每一帧。
|
||
#[tokio::test]
|
||
async fn a_session_without_mappings_is_not_retained() {
|
||
let state = redaction_enabled_state();
|
||
let hmac_key = state
|
||
.encryption_key()
|
||
.expect("the test state carries an encryption key")
|
||
.as_bytes()
|
||
.to_vec();
|
||
let empty_session = crate::privacy::RedactionSession::new(
|
||
crate::privacy::RedactionSessionConfig::default_ttl(hmac_key, 0),
|
||
);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(empty_session);
|
||
|
||
assert!(restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(FOREIGN_SENTINEL))
|
||
.expect("frame restoration must not fail")
|
||
.is_none());
|
||
}
|
||
|
||
/// 还原只作用于发给客户端的那一份拷贝:审计和终态观测消费的事件必须保持脱敏态。
|
||
#[tokio::test]
|
||
async fn restoring_does_not_mutate_the_event_the_audit_path_keeps() {
|
||
let state = redaction_enabled_state();
|
||
let redaction = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let sentinel = sentinel_for(&redaction, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(redaction.session);
|
||
|
||
let frame = provider_delta_frame(&format!("mail {sentinel}"));
|
||
let before = frame.clone();
|
||
let _ = restorer
|
||
.restore_provider_frame_text(&frame)
|
||
.expect("frame restoration must not fail")
|
||
.expect("the frame is restored for the client");
|
||
|
||
assert_eq!(
|
||
frame, before,
|
||
"capture_client_frame / 终态观测拿到的事件必须仍是脱敏态"
|
||
);
|
||
}
|
||
|
||
/// 连接级持有的意义:WS 的会话历史留在上游,continuation 只发增量输入,
|
||
/// 所以第 2 轮的响应可能回显第 1 轮的占位符。per-turn 持有会漏掉这一条。
|
||
#[tokio::test]
|
||
async fn a_later_turn_restores_a_placeholder_first_masked_by_an_earlier_turn() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let first = turn_redaction(&state, &decision, TEST_EMAIL).await;
|
||
let second = turn_redaction(&state, &decision, OTHER_TEST_EMAIL).await;
|
||
let first_sentinel = sentinel_for(&first, TEST_EMAIL);
|
||
let second_sentinel = sentinel_for(&second, OTHER_TEST_EMAIL);
|
||
assert_ne!(first_sentinel, second_sentinel);
|
||
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(first.session);
|
||
restorer.register(second.session);
|
||
|
||
let frame = provider_delta_frame(&format!("{first_sentinel} then {second_sentinel}"));
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&frame)
|
||
.expect("frame restoration must not fail")
|
||
.expect("both turns' sentinels are restorable on this connection");
|
||
|
||
assert!(restored.contains(TEST_EMAIL), "{restored}");
|
||
assert!(restored.contains(OTHER_TEST_EMAIL), "{restored}");
|
||
assert!(!restored.contains(&first_sentinel), "{restored}");
|
||
assert!(!restored.contains(&second_sentinel), "{restored}");
|
||
}
|
||
|
||
/// Omitting `previous_response_id` starts a new response chain. Restore
|
||
/// mappings from the prior chain must not leak into that independent
|
||
/// response, while the new chain's first-turn mapping remains available.
|
||
#[tokio::test]
|
||
async fn an_independent_chain_replaces_prior_restore_mappings() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let prior = turn_redaction(&state, &decision, TEST_EMAIL).await;
|
||
let current = turn_redaction(&state, &decision, OTHER_TEST_EMAIL).await;
|
||
let prior_sentinel = sentinel_for(&prior, TEST_EMAIL);
|
||
let current_sentinel = sentinel_for(¤t, OTHER_TEST_EMAIL);
|
||
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(prior.session);
|
||
restorer.start_new_chain(Some(current.session));
|
||
|
||
assert!(
|
||
restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(&prior_sentinel))
|
||
.expect("prior-chain restoration check must not fail")
|
||
.is_none(),
|
||
"an independent chain must not restore PII from its predecessor"
|
||
);
|
||
let restored = restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(¤t_sentinel))
|
||
.expect("the new chain's first-turn restoration must not fail")
|
||
.expect("the new chain's first-turn mapping must remain available");
|
||
assert!(restored.contains(OTHER_TEST_EMAIL), "{restored}");
|
||
assert!(!restored.contains(¤t_sentinel), "{restored}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn an_unredacted_independent_chain_clears_prior_restore_mappings() {
|
||
let state = redaction_enabled_state();
|
||
let prior = turn_redaction(&state, &control_decision(), TEST_EMAIL).await;
|
||
let prior_sentinel = sentinel_for(&prior, TEST_EMAIL);
|
||
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(prior.session);
|
||
assert!(restorer.has_sessions());
|
||
|
||
restorer.start_new_chain(None);
|
||
|
||
assert!(!restorer.has_sessions());
|
||
assert!(restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(&prior_sentinel))
|
||
.expect("cleared-chain restoration check must not fail")
|
||
.is_none());
|
||
}
|
||
|
||
/// 留存窗口是有界的:长连接不能无限累积映射,代价是更早的轮次会退回
|
||
/// 「占位符原样透传」而不是被错误还原成别的值。
|
||
#[tokio::test]
|
||
async fn the_retained_session_window_is_bounded() {
|
||
let state = redaction_enabled_state();
|
||
let decision = control_decision();
|
||
let oldest = turn_redaction(&state, &decision, TEST_EMAIL).await;
|
||
let oldest_sentinel = sentinel_for(&oldest, TEST_EMAIL);
|
||
let mut restorer = ResponsesWebSocketRedactionRestorer::default();
|
||
restorer.register(oldest.session);
|
||
|
||
// 再灌满整个窗口,最老的那一轮必须被挤出去。
|
||
let mut newest_sentinel = String::new();
|
||
for index in 0..MAX_RETAINED_TURN_REDACTION_SESSIONS {
|
||
let email = format!("ws.turn{index}@example.com");
|
||
let redaction = turn_redaction(&state, &decision, &email).await;
|
||
newest_sentinel = sentinel_for(&redaction, &email);
|
||
restorer.register(redaction.session);
|
||
}
|
||
|
||
assert!(
|
||
restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(&oldest_sentinel))
|
||
.expect("frame restoration must not fail")
|
||
.is_none(),
|
||
"the evicted turn's sentinel is relayed verbatim, never mis-restored"
|
||
);
|
||
assert!(
|
||
restorer
|
||
.restore_provider_frame_text(&provider_delta_frame(&newest_sentinel))
|
||
.expect("frame restoration must not fail")
|
||
.is_some(),
|
||
"the most recent turns stay restorable"
|
||
);
|
||
}
|
||
}
|