# Entra ID middleware 接入设计（Phase 1 → Phase 2 演进）

> **module**: internal-app-platform
> **doc_type**: Phase 1 design / Phase 2 migration plan
> **status**: Draft
> **last_verified**: 2026-05-14

## 1. 现状（Phase 1 当前）

### 1.1 JWT → binding 反查 fallback

后端 [`InternalAppPlatformService.resolveEmployeeSlug`](../../../backend/src/modules/internal-app-platform/internal-app-platform.service.ts)：

```ts
async resolveEmployeeSlug(user: { id?, userId?, employeeSlug? }): Promise<string | null> {
  // ① 已注入 employeeSlug 直接用（Entra middleware 落地后命中此分支）
  if (user.employeeSlug) return user.employeeSlug;

  // ② 兜底：从 JWT user.id 反查 employee_slug_bindings 表
  const userId = user.id ?? user.userId;
  if (!userId) return null;
  const binding = await prisma.employeeSlugBinding.findUnique({ where: { userId } });
  return binding?.employeeSlug ?? null;
}
```

### 1.2 当前流程

```
员工浏览器 → FFOA SSO（已有 Entra） → JWT
  → backend JwtAuthGuard 注入 req.user = { id, userId, email, username, ... }
  → 内部 internal-app-platform endpoints 调 resolveEmployeeSlug
  → DB 多查一次 employee_slug_bindings
```

**已经能跑**：FFOA 整个 OA 系统已挂在 Entra SSO 后面，员工进 OA 就是 Entra-authed。
internal-app-platform 只是没有专属 middleware 直接把 `mailNickname` 注入 req.user。

## 2. Phase 1 已落地（无独立 middleware 也能用）

- ✅ JWT 鉴权链工作（itadmin / 任意 OA 用户登录都能调 internal-apps endpoints）
- ✅ `getMyTokenStatus` 首次访问返 `hasToken=false`（不再 401），前端"生成新 token"流程顺畅
- ✅ `POST /tokens` 首次创建 binding：用 email/username 反推 mailNickname，
  调 `tokenSvc.issue()` 时 upsert binding（**`update: {}` 确保已存在 binding 的 slug 终身冻结**）
- ✅ admin 端点用 PermissionsGuard `internal-app:admin` 守

## 3. Phase 2 升级路径（不是 blocker，仅优化）

### 3.1 何时升级

满足任一即升：
1. 单点 Entra 用户首次接入路径上**多了一个查 binding 表的 RTT**，QPS 大了想去掉
2. 需要支持 mailNickname 改名（员工名字变化）触发 binding 重新评估
3. Entra 同步发现"用户已删除"想自动 disable token

### 3.2 设计：Pre-JWT middleware

```ts
@Injectable()
export class EntraEmployeeSlugMiddleware implements NestMiddleware {
  constructor(private readonly entraSvc: EntraService) {}

  async use(req: Request, _res: Response, next: NextFunction) {
    const user = (req as any).user;
    if (!user?.email || user.employeeSlug) return next();

    // 从 Entra 拉 mailNickname（缓存 5 min）
    const entraUser = await this.entraSvc.findByEmail(user.email);
    if (entraUser?.mailNickname) {
      user.mailNickname = entraUser.mailNickname;
      // employeeSlug 仍由 resolveEmployeeSlug 派生 + 兜底 binding 查询保护
    }
    next();
  }
}
```

挂载位置：`backend/src/app.module.ts` 全局 middleware，**JwtAuthGuard 之后**（拿到 user.email 之后）。

### 3.3 Slug 终身冻结保护（不变）

升级后**不可**把 `mailNickname → slug` 派生当作真理盖过现有 binding：员工改名（如离婚改姓）
后 mailNickname 变了，**slug 必须保持原值**，否则历史 app 路径 / Gitea 仓库 / MinIO 备份
全失效。`tokenSvc.issue` 已用 upsert + `update: {}` 模式（[token.service.ts L62](../../../backend/src/modules/internal-app-platform/services/token.service.ts)）正好保护此点。

### 3.4 离职联动（Entra disable）

[`scheduled-sync.service.ts`](../../../backend/src/modules/organization/sync/scheduled-sync.service.ts) 已轮询 Entra 用户状态。
Phase 2 增量：当 Entra user `accountEnabled=false` 时，对应 employee_slug_binding 触发：

```sql
UPDATE platform_internal_apps.internal_app_employee_tokens
   SET status='DISABLED', revoked_at=now(), revoked_reason='entra_disabled'
   WHERE employee_slug = (SELECT employee_slug FROM bindings WHERE user_id=?)
     AND status='ACTIVE';
```

token 自动失效；员工 app 容器**不停**（PRD：业务连续性优先；IT-Admin 决定下线时机）。

## 4. 决策

- **Phase 1 不引入独立 middleware**：当前 JWT + binding 反查工作正常，添加 middleware 是 premature optimization
- **Phase 2 触发条件**：上面 §3.1 任一满足 → 实现 §3.2 + §3.4
- **Phase 2 不破坏 slug 终身冻结**：upsert `update: {}` 已是保护，不可改为 `update: { employeeSlug: ... }`

## 5. 兼容性 contract

任何对 `req.user` 结构的修改都要保持向后兼容：

```ts
interface ResolvableUser {
  id?: string;       // JWT 注入
  userId?: string;   // JWT 注入（别名）
  email?: string;    // JWT 注入
  username?: string; // JWT 注入
  mailNickname?: string;   // Phase 2 Entra middleware 注入
  employeeSlug?: string;   // Phase 2 Entra middleware 直接注入（首选）
  organizationId?: string; // 跨模块通用
  currentOrganizationId?: string; // JWT slice 注入
}
```

[`resolveEmployeeSlug`](../../../backend/src/modules/internal-app-platform/internal-app-platform.service.ts) 的三档 fallback 顺序**不可调换**：先 `employeeSlug` 直查（Phase 2 中间件落地后零查询），再 binding 反查（Phase 1 现状），最后 null。

## 6. 测试矩阵

- ✅ Phase 1 单测 + L1 集成测试覆盖 JWT user.id 反查路径（[token.service.integration.test.ts](../../../testing/backend/integration/internal-app-platform/token.service.integration.test.ts)）
- ⏳ Phase 2 中间件落地时新增 L1：mock entraSvc → user.email → 期望 req.user.mailNickname 被注入 → resolveEmployeeSlug 命中第一档
