mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
fix(ws): restore redacted PII in provider frames before client delivery
Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。
- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
都不做。
测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
This commit is contained in:
@@ -336,6 +336,73 @@ async fn provider_quota_exhaustion_transparently_retries_onto_another_key() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 脱敏的另一半:请求侧把真实 PII 换成占位符发给上游,响应侧必须在推给客户端之前
|
||||
/// 换回真实值。
|
||||
///
|
||||
/// 上游把收到的 `input` 原样回显,所以它回来的就是占位符——这一条同时钉住了两个
|
||||
/// 方向:上游不能看到原文,客户端不能看到占位符。
|
||||
#[tokio::test]
|
||||
async fn redacted_pii_is_restored_before_the_client_sees_a_provider_frame() -> Result<(), BoxError>
|
||||
{
|
||||
const CLIENT_EMAIL: &str = "responses.ws.pii@example.com";
|
||||
|
||||
let harness = Harness::start_with_pii_redaction(UpstreamBehavior::EchoInputBack).await?;
|
||||
let mut client = harness.connect().await?;
|
||||
|
||||
client
|
||||
.send(response_create(
|
||||
json!({"input": format!("my mail is {CLIENT_EMAIL}")}),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let delta = receive_event(&mut client, "response.output_text.delta").await?;
|
||||
let delta_text = delta
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("the provider delta must carry text")?;
|
||||
assert!(
|
||||
delta_text.contains(CLIENT_EMAIL),
|
||||
"the client must receive the restored value: {delta_text}"
|
||||
);
|
||||
assert!(
|
||||
!delta_text.contains("<AETHER:"),
|
||||
"no redaction placeholder may reach the client: {delta_text}"
|
||||
);
|
||||
|
||||
// 请求侧仍然成立:上游只看到占位符,看不到原文。
|
||||
let upstream_events = harness.upstream.observed_events().await;
|
||||
assert_eq!(upstream_events.len(), 1);
|
||||
let upstream_input = upstream_events[0]
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("the upstream request must carry input")?;
|
||||
assert!(
|
||||
!upstream_input.contains(CLIENT_EMAIL),
|
||||
"the upstream must never see the raw PII: {upstream_input}"
|
||||
);
|
||||
assert!(
|
||||
upstream_input.contains("<AETHER:EMAIL:"),
|
||||
"the upstream must see the placeholder: {upstream_input}"
|
||||
);
|
||||
|
||||
// 还原只发生在最后一跳:终态照常到达,计费不受影响。
|
||||
let completed = receive_event(&mut client, "response.completed").await?;
|
||||
assert_eq!(
|
||||
completed
|
||||
.pointer("/response/status")
|
||||
.and_then(Value::as_str),
|
||||
Some("completed")
|
||||
);
|
||||
let audits = harness
|
||||
.usage_audits_where(1, "the billed redacted turn", is_billed)
|
||||
.await?;
|
||||
assert_eq!(audits.len(), 1);
|
||||
assert_eq!(audits[0].total_tokens, INPUT_TOKENS + OUTPUT_TOKENS);
|
||||
|
||||
client.close(None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_pending(audit: &StoredRequestUsageAudit) -> bool {
|
||||
audit.status.eq_ignore_ascii_case("pending")
|
||||
}
|
||||
@@ -384,21 +451,65 @@ impl ProviderFixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条用例要不要打开 chat PII 脱敏模块。
|
||||
///
|
||||
/// 默认关闭:其余用例都靠原文 body 断言上游看到了什么,打开脱敏会把断言目标换成
|
||||
/// 占位符。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PiiRedaction {
|
||||
Disabled,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
impl PiiRedaction {
|
||||
const fn is_enabled(self) -> bool {
|
||||
matches!(self, Self::Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn start(behavior: UpstreamBehavior) -> Result<Self, BoxError> {
|
||||
Self::start_with_fixture(behavior, ProviderFixture::SingleOpenAiKey).await
|
||||
Self::start_with(
|
||||
behavior,
|
||||
ProviderFixture::SingleOpenAiKey,
|
||||
PiiRedaction::Disabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_with_fixture(
|
||||
behavior: UpstreamBehavior,
|
||||
fixture: ProviderFixture,
|
||||
) -> Result<Self, BoxError> {
|
||||
Self::start_with(behavior, fixture, PiiRedaction::Disabled).await
|
||||
}
|
||||
|
||||
async fn start_with_pii_redaction(behavior: UpstreamBehavior) -> Result<Self, BoxError> {
|
||||
Self::start_with(
|
||||
behavior,
|
||||
ProviderFixture::SingleOpenAiKey,
|
||||
PiiRedaction::Enabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_with(
|
||||
behavior: UpstreamBehavior,
|
||||
fixture: ProviderFixture,
|
||||
redaction: PiiRedaction,
|
||||
) -> Result<Self, BoxError> {
|
||||
let upstream = Arc::new(MockUpstreamState::new(behavior));
|
||||
let upstream_server =
|
||||
SpawnedServer::start(mock_upstream_router(Arc::clone(&upstream))).await?;
|
||||
|
||||
let database = TemporarySqlite::new();
|
||||
prepare_and_seed_database(&database.config, upstream_server.base_url(), fixture).await?;
|
||||
prepare_and_seed_database(
|
||||
&database.config,
|
||||
upstream_server.base_url(),
|
||||
fixture,
|
||||
redaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let data_config = GatewayDataConfig::from_database_config(database.config.clone())
|
||||
.with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY);
|
||||
@@ -618,6 +729,11 @@ enum UpstreamBehavior {
|
||||
/// 第一轮刻意不发 `response.created`:任何标准 `response.*` 事件都会让
|
||||
/// codex adapter 把这一轮判成 replay-unsafe,透明重试就不会发生。
|
||||
QuotaExhaustedThenComplete,
|
||||
/// 把收到的 `input` 原样回显成一个 delta,再正常完成。
|
||||
///
|
||||
/// 上游看到的是脱敏后的 body,所以回显出来的就是占位符——正是响应侧还原要处理
|
||||
/// 的形状。
|
||||
EchoInputBack,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -688,6 +804,11 @@ async fn run_mock_upstream(
|
||||
if event.get("type").and_then(Value::as_str) != Some("response.create") {
|
||||
continue;
|
||||
}
|
||||
let echoed_input = event
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let turn = {
|
||||
let mut events = state.events.lock().await;
|
||||
events.push(event);
|
||||
@@ -719,6 +840,14 @@ async fn run_mock_upstream(
|
||||
break;
|
||||
}
|
||||
}
|
||||
UpstreamBehavior::EchoInputBack => {
|
||||
if send_mock_turn_with_delta(&mut socket, &response_id, &echoed_input)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AxumWsMessage::Ping(payload) => {
|
||||
@@ -766,13 +895,21 @@ async fn send_mock_created(socket: &mut WebSocket, response_id: &str) -> Result<
|
||||
}
|
||||
|
||||
async fn send_mock_turn(socket: &mut WebSocket, response_id: &str) -> Result<(), axum::Error> {
|
||||
send_mock_turn_with_delta(socket, response_id, "hello").await
|
||||
}
|
||||
|
||||
async fn send_mock_turn_with_delta(
|
||||
socket: &mut WebSocket,
|
||||
response_id: &str,
|
||||
delta: &str,
|
||||
) -> Result<(), axum::Error> {
|
||||
send_mock_created(socket, response_id).await?;
|
||||
send_mock_event(
|
||||
socket,
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"response_id": response_id,
|
||||
"delta": "hello"
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -848,6 +985,7 @@ async fn prepare_and_seed_database(
|
||||
database: &SqlDatabaseConfig,
|
||||
upstream_base_url: &str,
|
||||
fixture: ProviderFixture,
|
||||
redaction: PiiRedaction,
|
||||
) -> Result<(), BoxError> {
|
||||
let backends = DataBackends::from_config(DataLayerConfig::from_database(database.clone()))?;
|
||||
let pending = backends
|
||||
@@ -862,6 +1000,9 @@ async fn prepare_and_seed_database(
|
||||
seed_models(&backends).await?;
|
||||
let user_id = seed_user(&backends).await?;
|
||||
seed_client_api_key(&backends, &user_id).await?;
|
||||
if redaction.is_enabled() {
|
||||
seed_chat_pii_redaction(&backends).await?;
|
||||
}
|
||||
|
||||
let candidates = backends
|
||||
.read()
|
||||
@@ -1114,6 +1255,27 @@ async fn seed_client_api_key(backends: &DataBackends, user_id: &str) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打开 chat PII 脱敏:系统模块开关 + 这把 client key 的 feature 开关。
|
||||
///
|
||||
/// 规则集刻意不写:缺省即内置规则(含 email 规则),和生产上「只打开开关」的最小
|
||||
/// 配置一致。
|
||||
async fn seed_chat_pii_redaction(backends: &DataBackends) -> Result<(), BoxError> {
|
||||
backends
|
||||
.upsert_system_config_entry("module.chat_pii_redaction.enabled", &json!(true), None)
|
||||
.await?;
|
||||
backends
|
||||
.write()
|
||||
.auth_api_keys()
|
||||
.ok_or("auth API key writer unavailable")?
|
||||
.set_standalone_api_key_feature_settings(
|
||||
API_KEY_ID,
|
||||
Some(json!({"chat_pii_redaction": {"enabled": true}})),
|
||||
)
|
||||
.await?
|
||||
.ok_or("failed to enable chat PII redaction on the E2E API key")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &str) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
|
||||
Reference in New Issue
Block a user