# ERR-20260519-010 SSO audit 写入与业务 tx 不同连接，导致 JIT 路径 FK 违例

## 症状

L1 集成测试 `testing/backend/integration/organization/sso.api.test.ts`
跑 5.3.4（JIT 建账号）/ 5.3.13（CAS 并发回填）/ 5.3.14（并发 JIT 同 email）时：

```
AuditService.log -> prisma.auditLog.create() ->
P2003 Foreign key constraint violated on the constraint: `audit_log_user_id_fkey`
```

业务 redirect 仍然 302 成功（fragment 含 token），但 `SSO_JIT_CREATED` /
`SSO_BINDING_FILLED`（新建用户场景）/ `SSO_LOGIN_SUCCESS` 三类 audit **丢失**。

## 根因

`backend/src/modules/organization/auth/auth.service.ts` § `writeSsoAudit`（L590-601）
显式注释：

> 复用 AuditService.log 但因 AuditService 自带 prisma 写 + 哈希链，无法直接传 tx 用同一连接。
> 这里**故意**在事务外写 audit 在概念上不严格——
> ...本期决策接受这一不严格性，原因：
>   - audit 失败不应回滚业务
>   - 引入 tx-aware audit 改造影响面太大

业务流程：

1. `prisma.$transaction(async (tx) => { ... })` 启动业务 tx
2. tx 内 `tx.user.upsert/create` 新建 user
3. tx 内 `writeSsoAudit(tx, ...)` → 内部调 `auditService.log({userId: newUser.id})`
4. `auditService.log` 用**独立 prisma 实例**（与 tx 不同连接），看到的是 **PRE-tx
   状态**，新 user 在 DB 上**还没 commit**
5. `auditLog.create({data: { userId: newUser.id }})` → P2003 FK 违例

`SSO_BINDING_FILLED`（更新已存在 user）/ `SSO_LOGIN_SUCCESS`（更新已存在 user）
**不**触发：user 在 tx 之前就存在，外部 connection 看得到，FK 校验通过。

`SSO_JIT_CREATED`（新建 user）= 必中。

## 影响

| 用例 | path | audit 落库 | 影响 |
|---|---|---|---|
| 5.3.3 binding_filled（已存在 user） | binding_filled | ✅ 全落 | 无 |
| 5.3.4 JIT 新建 | jit | ❌ 全丢（JIT + LOGIN_SUCCESS） | 审计盲区 |
| 5.3.9b LDAP 升级（已存在 user） | ldap_upgraded | ✅ 全落 | 无 |
| 5.3.13 并发 CAS（已存在 user） | binding_filled | ✅ 全落 | 无 |
| 5.3.14 并发 JIT | jit | ❌ 全丢 | 审计盲区 |

业务功能本身（302 + 用户登录 + DB 写入）**完全正常**——只是审计日志在"新建 user
+ 同事务 audit"组合下丢。

## 建议

短期（不阻塞当前 PR）：
- L1 测试加软断言 + 告警注释，记录预期 vs 实际差异。

长期（独立 issue）：
- 选项 A：把 `writeSsoAudit` 内部的 `AuditService.log` 调用迁到 tx 提交后（post-commit）。
  代价：audit 落库时机晚于业务 commit，崩溃窗口里业务已成功但 audit 缺失。
- 选项 B：改造 `AuditService` 接受 `tx: Prisma.TransactionClient` 参数，全局收益更大
  但影响范围广。
- 选项 C（已采用临时方案）：在 tx 内写 audit 失败时静默——这是当前实施。

## L1 测试当前处理

5.3.4 用 `if (jitEvent) { ... expect(...) } else { console.warn(...) }` 软断言；
5.3.13 / 5.3.14 用 `expect(events.length).toBeLessThanOrEqual(1)`，0 / 1 都允许。

当 audit-tx 问题修复后，可把 5.3.4 改回硬 `expect(jitEvent).toBeTruthy()`。

## 关联

- 任务：issue #334（Microsoft Entra SSO L1 集成测试）
- 测试文件：`testing/backend/integration/organization/sso.api.test.ts`
- 源码：`backend/src/modules/organization/auth/auth.service.ts` L590-601
- 文档：`docs/modules/organization/09-test-scenarios.md` §5.3.4

## 修复（2026-05-19 当晚）

按方案 A 修复：JIT/LOGIN_SUCCESS audit 从 tx 内移到 tx 提交后调。

实施细节：
- `loginViaSSO` 引入 `pendingAuditEvents: PendingAuditEvent[]` 数组累积成功路径事件
  （`SSO_BINDING_FILLED` / `SSO_BINDING_UPGRADED_FROM_LDAP` / `SSO_JIT_CREATED` /
  `SSO_LOGIN_SUCCESS`），tx 提交后循环调 `this.auditService.log(...)`，每条独立
  try/catch 不重抛、不影响业务结果。
- 失败路径事件（`SSO_BINDING_CONFLICT` / `LOGIN_FAILED`）仍走 tx 内 `writeSsoAudit`
  即时写入——它们要求"业务 rollback 但 audit 必须保留"（合规审计），且不涉及未提交 user FK。
- `writeSsoAudit` 函数签名 + 失败容忍 try/catch 保留；注释更新澄清"仅用于失败路径"。
- 崩溃窗口（tx commit → audit 写入前进程崩溃）audit 会丢——这是接受的代价，与
  "audit 失败不应回滚业务"原则一致。

代码：`backend/src/modules/organization/auth/auth.service.ts` § `loginViaSSO`
post-commit `pendingAuditEvents` 循环。

测试：`testing/backend/integration/organization/sso.api.test.ts`
- 5.3.4：`if (jitEvent) { expect(...) }` 软断言 → 硬断言 `expect(jitEvent).toBeTruthy()` +
  必跑 metadata 校验；`SSO_LOGIN_SUCCESS` 同步硬断言。
- 5.3.13：`toBeLessThanOrEqual(1)` → `toBe(1)`（CAS 保证 binding_filled 路径恰好 1 条 audit）
- 5.3.14：`toBeLessThanOrEqual(1)` → `toBe(1)`（upsert + P2002 fallback 保证 JIT 路径恰好 1 条 audit）

验证：`cd backend && npm run build` 0 error；
`testing && bash scripts/run-backend-integration.sh backend/integration/organization/sso.api.test.ts`
33/33 测试用例全过，无软断言残留。
