# Permission System Security Refactor — Working Document

**Status:** Research Phase (pre-design)
**Date:** 2026-04-04
**PR:** #655 (on hold pending refactor)

---

## 1. 漏洞全景（40+ 个）

> 经过 Dark Area 调研，漏洞总数从 33 扩展到 40+。新增 7 个来自 permission resolution engine 和 setup×RLS 交叉区域。

### 原始审计：28 个漏洞，4 个系统性根因

| Root Cause | 漏洞数 | 代表性问题 |
|------------|-------|-----------|
| RC1: Generic CRUD 绕过 setup 对象保护 | 8 | ObjectPermissions/FieldPermissions 无 gate → 提权；Session 可读 → token 窃取 |
| RC2: RLS 三路径三实现，都不完整 | 4 | RunAnalytics 零 RLS；Subquery 零 RLS；两条 SQL 路径只覆盖 4/7 层 |
| RC3: Session 生命周期不完整 | 6 | OAuth/SSO 绕过 lockout；密码变更不杀 session |
| RC4: 内部方法绕过权限检查 | 3 | Get()/GetMany() 零权限检查；hydrateLookups 泄露不可访问记录 |

### 补充审计：5 个新漏洞（不在 4 个 Fix 覆盖范围内）

| # | 漏洞 | 严重度 | 位置 |
|---|------|--------|------|
| N1 | **Webhook SSRF** — 无 URL/IP 校验，可访问云 metadata service（169.254.169.254） | CRITICAL | `action_service.go:264-327` |
| N2 | **文件下载无授权检查** — 任何认证用户可下载任何已上传文件 | HIGH | `file_upload_handler.go:136-188` |
| N3 | **Auth 端点共享全局 rate limit（100/s）** — 暴力破解不受限 | HIGH | `rate_limit.go` + `routes.go:48-51` |
| N4 | **RLS via FROM 子查询绕过** — `applyRLS` 跳过非 TableName 的 source | MEDIUM | `security_validator.go:280-282` |
| N5 | **错误响应泄露内部信息**（SQL fragment、hostname、TiDB error code） | MEDIUM | 多处 handler 的 `err.Error()` 直传 |

---

## 2. Salesforce 调研结论

### RC1: Setup 对象保护 — SF 三层防护

1. **Metadata 标记**：`EntityDefinition.isSetupObject`、`createable`/`updateable`/`deletable` 控制每个 object 的 API 可操作性
2. **API 路由隔离**：Standard REST API（data objects）、Tooling API（dev artifacts）、Metadata API（schema changes）分离
3. **System permission gate**：`ManageUsers`、`ManageProfilesPermSets`、`CustomizeApplication` 等始终检查
4. **Mixed DML 隔离**：同一事务不能混合修改 setup 和 non-setup objects

**NexusCRM 现状**：`_System_Object` 表已有 `tableType` 字段（`system_core`/`system_metadata`/`custom_object`），CRUD 路径从未读取。

### RC2: RLS 一致性 — SF 物化 Share 表

1. **Share 表（物化）**：每个 Private OWD 对象有 `<Object>Share` 表，物化所有访问授权
2. **Group Maintenance 表（物化）**：预计算用户→组映射（包括 role hierarchy 转组）
3. **Query = JOIN**：SOQL 查询 JOIN Share 表 + Group Maintenance 表，和单条记录检查读同一张表
4. **`with sharing` / `without sharing`**：class 级别声明式关键字

**NexusCRM 决策**：127 用户不需物化，**实时 WHERE 子查询**是正确选择。crossover point ≈ 5000-10000 用户。

### RC3: Session 生命周期 — SF 行为

| 事件 | SF 行为 | NexusCRM 现状 |
|------|---------|-------------|
| 密码变更 | 立即撤销所有 session | ❌ 不撤销 |
| 账户停用 | 立即撤销所有 session | ✅ 已实现 |
| 账户锁定 | 阻止新认证，**不杀**已有 session（设计选择） | ❌ 不检查 lockout（OAuth/SSO 绕过） |
| Admin 重置密码 | 撤销 session + 通知用户 | ❌ 不撤销 |
| Profile/Role 变更 | 不撤销 session，依赖 cache TTL 刷新 | ✅ cache invalidation 已实现 |

### RC4: System Context — SF `with sharing` 语义

| SF 概念 | 含义 | NexusCRM 对应 |
|---------|------|-------------|
| `with sharing` | 默认。强制 RLS（sharing）。CRUD+FLS 始终检查 | 无对应（应为默认） |
| `without sharing` | 绕过 RLS，**不绕过** CRUD/FLS | `ContextKeyInternalWrite`（但实际绕过 FLS） |
| System Mode（triggers/workflows） | 绕过 ALL（包括 CRUD/FLS） | fake `systemContext` UserSession（`IsSystemAdmin: true`） |

---

## 3. 提出的 4 个修复方案及 Critic 评审结果

### Fix 1: Metadata-driven setup object gate — NEEDS WORK

**提案**：在 `_System_Object` 上加 `IsSetupObject`/`RequiredSystemPermission` 等字段，CRUD 路径读 metadata 决策。

**Critic 发现的问题**：
- ❗ `ObjectMetadata` struct 没有 `TableType` 字段 — 需要先加字段到 struct 并在 schema scan 时填充
- ❗ 非 `_System_` 前缀的 setup objects（`ObjectPermissions`、`FieldPermissions`、`PermissionSetGroup` 等 6 个表）stopgap 覆盖不到
- 5 个新 metadata 字段过度设计，2 个够了（`IsSetupObject` + `RequiredSystemPermission`）
- Route-level enforcement 仍然需要（defense-in-depth），metadata-only 不够

### Fix 2: Single RLS function — NEEDS WORK

**提案**：`BuildRLSResult` → 结构化数据 → string adapter + AST adapter。覆盖 7 层。

**Critic 发现的问题**：
- ❗ `RLSResult` struct vs string output 两个设计互相矛盾 — 需要选一个
- ❗ 需指定 `_System_RecordShare` 和 `_System_TeamMember` 的索引
- ❗ `formula.ToSQL` 已存在（19 处引用），criteria-based sharing rules 不该标为"不可行"
- `IN (100+ values)` 在 TiDB 上可接受（hash probe），但加上 EXISTS 子查询需要性能测试

### Fix 3: Session lifecycle — NEEDS WORK

**提案**：密码变更/admin 重置/lockout 都 revoke session。`CreateSessionForUser` 加 lockout 检查。

**Critic 发现的问题**：
- ❗ `GetUserByID`（`user_repository.go:341`）可能不返回 `LockedUntil` 字段 — 需要确认
- ❗ Goroutine timeout 不应混入此 fix — 独立跟踪
- 原子 `IncrementFailedAndLockIfNeeded` 的 SQL `UPDATE ... CASE WHEN` 在 TiDB 单行更新上是原子的 ✅

### Fix 4: SecurityContext enum — NEEDS WORK

**提案**：三级 `UserContext`/`SystemContext`/`PrivilegedContext` 替代现有两套 bypass。

**Critic 发现的问题**：
- ❗ `SystemContext` 定义说"绕过 sharing，保留 CRUD+FLS"，但 `ContextKeyInternalWrite` 实际绕过 FLS（approval workflow 需要）— 语义不匹配
- ❗ `context.Value` 和 SF 的 class-level `without sharing` 一样不可见 — 需要审计日志 + `reason` 参数
- 需要先审计每个 `ContextKeyInternalWrite` 调用方实际需要绕过什么

---

## 4. 调研结果（Q1-Q4 已完成）

### Q1: `GetUserByID` 是否返回 `LockedUntil`？ — **NO**

**证据**：`user_repository.go:341-381` 只 SELECT 7 列：`ID, Username, Email, ProfileID, FirstName, LastName, IsActive`。不含 `LockedUntil` 和 `FailedLoginAttempts`。

**Tacit design pattern**：开发者建了 **two-tier query 模式**：
- Light tier（`GetUserByID`）— 7 列，用于 session 构建和显示
- Auth tier（`FindUserByEmailWithPassword`）— 11 列，含安全字段，仅用于 Login

**隐含假设**：lockout 只在 login 时检查。`CreateSessionForUser` 调用 light tier，所以 `user.LockedUntil` 永远是 nil（零值）→ lockout check 形同虚设。

**修复影响**：需要给 `GetUserByID` 的 SELECT 加 `LockedUntil` + `FailedLoginAttempts`（2 列，开销可忽略）。Option A（直接加列）是正确选择——session creation 现在是安全边界，two-tier 优化不再适用。

### Q2: 是否有 criteria-based sharing rules？ — **NO（但 UI 完全暴露）**

**证据**：
- `system_data.json`：零条 SharingRule seed data
- 唯一 criteria 只在测试中：`sharing_rules_test.go:95` → `industry == "Technology"`
- `SharingRuleForm.tsx:215-221`：UI 完全暴露 `FormulaEditor` 组件，admin 可以创建

**Tacit design pattern**："schema-first, data-later" — 系统 bootstrap 只建表和 UI，安全策略数据由 admin 运行时配置（与 SF 一致）。

**Gap 方向**：RLS 查询路径不支持 criteria-based rules，但这产生的是 **false-negative**（查询结果少于实际可访问，安全方向），不是 false-positive。`formula.ToSQL` 已存在（19 处引用），技术上可以支持大部分 criteria。

**修复影响**：criteria-based sharing rules 可标为 "Phase 2"，当前无实际数据。但设计文档需要明确说明这是 known limitation + 安全方向。

### Q3: `ContextKeyInternalWrite` 各调用方需要绕过什么？ — **四级模型，不是三级**

**证据**：4 个 SET 点（全在 ApprovalService），5 个 CHECK 点（persistence 层），外加 SystemManager 的 fake admin session（5 个实例）。

**完整绕过矩阵**：

| 调用方 | Process-field | Password | FLS-Create | FLS-Update | CRUD | RLS |
|--------|:---:|:---:|:---:|:---:|:---:|:---:|
| ApprovalService（4 处） | ✅ bypass | ✅ bypass* | ✅ bypass | ❌ enforced | ❌ enforced | ❌ enforced |
| SystemManager（5 处 fake admin） | ❌ enforced | ✅ bypass | ✅ bypass | ✅ bypass | ✅ bypass | ✅ bypass |

*ApprovalService 绕过 password write 是**附带效果**，非有意设计。它从不写密码。

**Tacit 四级安全模型**（开发者未明说但代码体现）：

| 级别 | 含义 | 对应代码 |
|------|------|---------|
| **UserContext** | 所有检查生效 | 默认 |
| **ServiceContext** | 绕过 process-field + FLS-on-Create，保留 CRUD/FLS-on-Update/RLS | `ContextKeyInternalWrite` |
| _(gap)_ | _(当前无代码占据此级)_ | |
| **SystemContext** | 绕过 ALL（通过 fake admin identity） | `system_manager.go` fake UserSession |

**关键洞察**：三级 enum 不够。`SystemContext` 如果定义为 "bypass sharing, preserve CRUD+FLS"，会 break ApprovalService（它需要 bypass FLS-on-Create）。如果定义为 "bypass all"，又过度授权。需要 **ServiceContext** 作为独立级别。

**修复影响**：SecurityContext 应该是四级，`ServiceContext` 要求 `reason` 参数用于审计。

### Q4: `ObjectMetadata` 是否有 `TableType`？ — **NO（有损压缩丢失）**

**证据**：完整数据流追踪：

```
system_tables.json (tableType: "system_core")           ← 6 值分类 ✅
  → Bootstrap writes to _System_Object.TableType column  ← DB 有 ✅
    → metadata_scan_schema.go:56-63 reads tableType       ← 读到了 ✅
      → switch → obj.IsSystem = true/false                 ← 6 值 → 1 bit 有损压缩 ❌
        → tableType 变量 goes out of scope                 ← 丢弃 ❌
          → ObjectMetadata struct: 无 TableType 字段       ← runtime 读不到 ❌
            → service.go:81-104: hardcoded switch           ← 用名称补偿 ❌
```

**丢失点**：`metadata_scan_schema.go:56-63` — `tableType` 被 switch 消费后未赋值给 `obj`。

**Tacit insight**：两套 schema 表示（`TableDefinition` for bootstrap DDL vs `ObjectMetadata` for runtime API）存在合理原因（不同生命周期的不同关注点）。`tableType` 丢失是**设计疏忽**，不是有意抽象——原始代码只需要 boolean `IsSystem`，没人加完整分类。

**修复路径**（3 个文件，~40 行）：
1. `metadata.go:148` — `ObjectMetadata` 加 `TableType constants.TableType` 字段
2. `metadata_scan_schema.go:63` — `obj.TableType = constants.TableType(tableType.String)`
3. `service.go:81-104` — `requiredSystemPermissionsForSetupObject` 改为读 `obj.TableType` 而非 hardcoded name switch

### 剩余待调研问题（中/低优先级）

| # | 问题 | 状态 | 如何回答 |
|---|------|------|---------|
| Q5 | `formula.ToSQL` 能处理哪些 criteria 表达式？缺什么？ | 未调研 | 读 `expression/walker.go` 的 `visitCall` switch |
| Q6 | `_System_RecordShare` 和 `_System_TeamMember` 的索引？ | 未调研 | 查 DB schema |
| Q7 | `hydrateLookups` Name 泄露是否匹配 SF 行为？ | 未调研 | SF 文档确认 |
| Q8 | Webhook SSRF 修复纳入重构还是独立？ | 未调研 | 评估依赖关系 |
| Q9 | 哪 4 个 goroutine 缺 timeout？ | 部分：auth_service.go:88, setup_audit.go:29, oauth_handler.go:51, + 1 | 完整搜索 |
| Q10 | unknown schema → nil（绕过 RLS）应返回 deny-all？ | 未调研 | 设计决策 |
| Q11 | Delete 限制列表错误表名 | 未调研 | 查 constants |

---

## 5. Dark Area 调研结果（DA1-DA3）

### DA1: Permission Resolution Engine — 发现 4 个 bug

Permission resolution 是整个安全系统的基础。我们之前作为 subsidiary tool 使用它，从未作为 focal target 审查。

| # | Bug | 严重度 | 位置 | 影响 |
|---|-----|--------|------|------|
| DA1-1 | **Muting 作用域过宽** — `delete(effective, mutedPerm)` 删除整个 effective map，不限制在 PSG 内。PSG-A 的 MutingPermissionSet 会撤销 Profile 和 PSG-B 的权限 | CRITICAL | `perm_system.go:118-120` | 违反设计文档 line 120："Muting only affects within its parent PSG" |
| DA1-2 | **Object/Field permission 遗漏 PSG component** — `LoadEffectiveObjectPermission` 的 SQL UNION 只含 profile-owned + directly-assigned，缺 PSG component 路径 | HIGH | `perm_object_field.go:102-116` | 通过 PSG 授予的 ViewAllRecords/ModifyAllRecords 对 object/field 检查不生效 |
| DA1-3 | **Group query 多余 JOIN** — Layer 4 的 SQL 多了一个 `JOIN PermissionSetAssignment`，变成 intersection 而非 union | HIGH | `perm_system.go:260` | PSG component PermSet 必须同时被直接 assign 才出现在 Layer 4 |
| DA1-4 | **System permission 变更无 cache invalidation** — `GrantProfilePermission`/`RevokeProfilePermission` 不调 `InvalidateCache` | MEDIUM | `perm_system.go:367-399` | 权限变更后 5 分钟内不生效 |

**Tacit insight (Polanyi)**: Layer 2（boolean columns）正确包含了 PSG components，但 Layer 3-4（normalized rows）和 object/field queries 遗漏了。这表明 boolean column path 是后来加入的更完整实现，而 normalized row path 是早期的不完整实现——两者共存但从未对齐。

### DA2: Metadata Configuration Tampering — 数据收集完成，分析部分完成

Agent 在 rate limit 前已确认核心事实：

- `_System_Object` **不在** `requiredSystemPermissionsForSetupObject` switch 里
- 因此 `SharingModel` 字段可通过 generic CRUD 被任何有 CRUD 权限的用户修改
- 攻击链：PATCH `_System_Object` 把 `SharingModel` 改为 `PublicReadWrite` → RLS 对该 object 立即关闭

**DA2 完整分析已完成**。完整结果：

#### Security-Relevant Metadata 可篡改性清单

| 字段 | 所在表 | 安全影响 | Generic CRUD 可改？ | 攻击链 |
|------|--------|---------|-------------------|--------|
| `SharingModel` | `_System_Object` | 改为 PublicReadWrite → 关闭 RLS | **YES — 无 gate** | Chain A (CRITICAL) |
| `EnableHierarchySharing` | `_System_Object` | 控制 role hierarchy 是否生效 | **YES — 无 gate** | Medium |
| `IsSystem` (object) | `_System_Object` | 改为 false → 允许 CRUD 系统对象 | **YES — 无 gate** | Chain D (MEDIUM) |
| `Createable` | `_System_Field` | 改为 true → 允许写审计字段 | **YES — 无 gate** | Chain B (HIGH) |
| `Updateable` | `_System_Field` | 改为 true → 绕过 FLS | **YES — 无 gate** | Chain B (HIGH) |
| `IsSystem` (field) | `_System_Field` | 改为 false → 允许修改/删除系统字段 | **YES — 无 gate** | Chain D (MEDIUM) |
| `Profile.IsSystem` | `Profile` | `IsSuperUser` 检查硬编码 profile ID，NOT viable | 需 ManageProfilesPermSets | Chain C **NOT VIABLE** |

**Chain B 缓解因素**：`FieldAccessResolver.EffectiveCreateable()` 用 `ComputeIntrinsic()`（代码计算），不读 DB。Chain B 对使用 resolver 的写入路径无效。但 describe API 和 UI API 直接读 DB 值返回前端。

**根因**：`_System_Object` 和 `_System_Field` 不在 `requiredSystemPermissionsForSetupObject` switch 里。

#### Frontend + Bootstrap 边界检查

| 区域 | 状态 | 发现 |
|------|------|------|
| Frontend | SAFE | 后端用 `ComputeIntrinsic` 做 FLS enforcement，不信任 DB 值。篡改只产生前端 cosmetic 效果 |
| Bootstrap | CONCERN | `schema.go:280` 每次启动重置 `SharingModel=PublicReadWrite`。admin 收紧的配置被覆盖。修复：从 `schema_metadata_persist.go:200` 的 `updatableFields` 移除 SharingModel |

### DA3: Setup × RLS Intersection — 发现 1 个 critical bug + SF parity gaps

| # | 发现 | 严重度 | 影响 |
|---|------|--------|------|
| DA3-1 | **标准用户无法查询 User 表** — `requiredSystemPermissionsForSetupObject("User", "Read")` 返回 `["ManageUsers"]`，不区分 Read/Write | CRITICAL（功能性） | SF 允许所有用户 READ User/Profile/Role |
| DA3-2 | **Profile、UserRole、Group 同样 Read 被阻断** | HIGH | 前端 dropdown、权限检查、hierarchy 显示全部依赖 Read |
| DA3-3 | **所有对象 bootstrap 为 `PublicReadWrite`** — `schema.go:280` blanket 设置 | HIGH | RLS 对所有对象都是 no-op，掩盖了其他 RLS bug |

**核心发现 — SF 的 "Read-exempt, Write-gated" 模型**：

SF 的 setup objects 对 **Read 操作不要求 system permission**。User、Profile、UserRole、Group 是系统运行必需的参考数据，所有认证用户都能读。System permission（ManageUsers 等）只控制 Create/Edit/Delete。

NexusCRM 当前 blanket-gate Read + Write，导致标准用户无法完成基本操作。

---

## 6. 设计原则（已确立）

### 从 Salesforce 调研中确立的原则

| # | 原则 | SF 依据 | NexusCRM 适配 |
|---|------|---------|-------------|
| P1 | **Object taxonomy drives access policy** — 平台分类 object，不是应用代码 | `EntityDefinition.isSetupObject` metadata flag | 用 `_System_Object.tableType` + 新增 `IsSetupObject`，不硬编码 |
| P2 | **Single source of truth for RLS** — query 和 single-record 读同一数据源 | Share 表 + Group Maintenance JOIN | 单一 `BuildRLSFilter` 函数，覆盖全部 7 层 |
| P3 | **Default deny, additive only** — 缺少授权 = 可见错误；缺少限制 = 静默泄露 | OWD=Private baseline | 所有新 object 默认 Private；unknown schema → deny-all（不是 skip） |
| P4 | **Security events → immediate invalidation** — 不等 TTL | 密码变更立即 revoke session | 密码变更/admin 重置/lockout 触发 revoke |
| P5 | **Declarative security context** — 代码中明确声明 bypass | `with sharing` / `without sharing` class keyword | `SecurityContext` enum + `reason` 审计参数 |
| P6 | **Defense-in-depth** — gate 在 route 层 + service 层 + repo 层 | API routing + permission check + Share table | Route middleware + `CheckObjectPermission` + RLS filter |
| P7 | **Metadata-driven, not hardcoded** — 安全策略是数据，不是代码 | Permission 存在 PermissionSet/ObjectPermissions 表 | 已实现。setup object 分类也应 metadata-driven |

### 从 Critic 评审中确立的原则

| # | 原则 |
|---|------|
| P8 | **Stopgap 必须覆盖非 `_System_` 前缀的 setup objects** — `ObjectPermissions`、`FieldPermissions` 等 |
| P9 | **一个设计，不是两个** — `RLSResult` struct 和 string output 必须选一个 |
| P10 | **不把 goroutine timeout 混入安全重构** — 独立跟踪 |
| P11 | **SecurityContext 需要四级，不是三级** — Q3 调研确认：UserContext / ServiceContext / (gap) / SystemContext。三级会 break ApprovalService 或过度授权 |
| P12 | **`tableType` 从 DB 到 runtime 的数据流必须无损** — Q4 调研确认：6 值分类在 `scanObject` 被有损压缩为 1 bit boolean，导致 hardcoded switch 补偿 |
| P13 | **Security tier 的 two-tier query pattern 不再适用** — Q1 调研确认：`GetUserByID` 的 light tier 不含安全字段，session creation 现在是安全边界，必须升级 |
| P14 | **Security configuration 本身是攻击面** — DA2 确认：`_System_Object.SharingModel` 可被篡改关闭 RLS。enforcement 的 input 和 enforcement 本身同等重要 |
| P15 | **Setup objects 用 Read-exempt, Write-gated 模型** — DA3 确认 SF 行为：User/Profile/Role/Group 对 Read 不要求 system permission。参考数据是系统运行前提 |
| P16 | **Permission resolution 的 dual-path 必须对齐** — DA1 确认：boolean column path 正确含 PSG，normalized row path 遗漏。两条路径共存但从未对齐 |
| P17 | **Muting 必须 scoped to parent PSG** — DA1 确认：当前全局 delete 违反设计文档。需要 per-PSG 计算再 union |
| P18 | **SharingModel 不能 blanket 设为 PublicReadWrite** — DA3 确认：`schema.go:280` 的默认值让 RLS 对所有对象都是 no-op，掩盖了所有 RLS bug |
| P19 | **Security configuration metadata 必须与 enforcement metadata 同等保护** — DA2 确认：`_System_Object`/`_System_Field` 配置了 enforcement 规则，但自身无保护。SF 的 metadata catalog 不是可 PATCH 的 sObject |
| P20 | **Bootstrap 不可覆盖 admin 配置** — DA2 Bootstrap 检查确认：SharingModel 在 UPSERT 的 `updatableFields` 中，每次启动重置。应改为 INSERT-only（只设首次默认值） |
| P21 | **ComputeIntrinsic 是 defense-in-depth 的正确模式** — DA2 Frontend 检查确认：FLS enforcement 用代码计算而非 DB 值，使 metadata 篡改对写入路径无效。其他 enforcement 点应学习此模式 |

---

## 6. 实施顺序（Critic 建议）

```
Week 1: Fix 3 (Session) — 最小范围、最高安全影响
  ├── CreateSessionForUser 加 lockout check
  ├── ChangePassword 加 session revocation
  ├── 原子 IncrementFailedAndLockIfNeeded
  └── Admin 密码重置加 session revocation

Week 1 (parallel): Fix 1 stopgap — 扩展 hardcoded switch
  └── 加入 ObjectPermissions, FieldPermissions, PSG*, Muting, RecordShare, Session, OAuthCode

Week 2-3: Fix 2 (RLS 统一)
  ├── 先堵 executeSubQueries + RunAnalytics 的 RLS 漏洞
  ├── 然后统一为单一 BuildRLSFilter 函数
  └── 加 RecordShare + TeamMember + Group ownership 子查询

Week 3-4: Fix 1 完整版 (metadata-driven gate)
  ├── ObjectMetadata 加 TableType/IsSetupObject
  └── CRUD 路径读 metadata 决策

Week 4+: Fix 4 (SecurityContext) — 最低紧急度
  ├── 先审计所有 ContextKeyInternalWrite 调用方
  └── 再定义 enum 级别

独立修复（不在主重构中）:
  - N1: Webhook SSRF URL/IP 校验
  - N2: 文件下载授权检查
  - N3: Auth 端点 rate limit
  - N5: 错误响应信息泄露
  - Goroutine timeout（4 处）
```

---

## 8. 设计修正记录（Design v2 基于 4-Expert Review + B1/B2/A1-A8 调研）

### B1 修正：Owner-based Sharing Rule

**错误**：criteria-less sharing rule → `Skip=true`（全部记录可见）
**事实**：SharingRule 模型**缺少** `SharedFromRoleId`/`SharedFromGroupId` 字段。没有 "from" 端数据。
**修正**：
1. `SharingRule` 表加 2 列（`SharedFromRoleId`, `SharedFromGroupId`）
2. `RLSResult` 加 `SharingRuleOwnerIDs []string` 字段
3. Layer 7 改为：if user ∈ ShareWith AND record.OwnerId ∈ SharedFrom users → 可见
4. 现有 `checkSharingRuleAccess` 同样需要修正（`sharing_rules.go:113` 的 `return true` 改为 `recordOwnerMatchesSharedFrom`）
5. 修正测试 `sharing_rules_test.go:220`

### B2 修正：ControlledByParent + Implicit Sharing

**错误**：Contact 默认 `PublicRead`，SF 是 `ControlledByParent`
**事实**：NexusCRM 已有 `_System_Relationship` 表 + `Relationship` model + `IsMasterDetail`。基础设施存在。
**修正**：
1. `enums.go` 加 `SharingModelControlledByParent`
2. `RLSResult` 加 `ParentFilter *ParentRLSFilter`（递归结构）
3. `BuildRLSResult` 新增 Layer 1b：ControlledByParent → 查 parent relationship → 递归构建 parent RLS
4. Adapter 产生嵌套子查询：`AccountId IN (SELECT Id FROM Account WHERE <Account RLS>)`
5. Contact 默认改为 `ControlledByParent`
6. Child-to-parent implicit sharing 延后

### B3 修正：nil schema → deny-all

**已在 DESIGN.md Section 8.7 处标注，但 BuildRLSResult 实现与之矛盾**
**修正**：`BuildRLSResult` 顶部加：`if schema == nil { return &RLSResult{} }` → adapter 产生 `1=0`

### A1-A8 工程 Spec 补充

| 节 | 内容摘要 |
|----|---------|
| A1 | 测试计划：30+ 个测试用例，每个漏洞对应 regression test |
| A2 | PR 拆分：4 个独立 PR（SecurityContext+AccessClassification → RLS → Session → PermResolution+Bootstrap）。PR1+PR4 有依赖，PR3 完全独立可并行 |
| A3 | 一致性合约：`BuildRLSResult` 是 read-path **superset pre-filter**，`CheckRecordAccess` 是 **authoritative gate** |
| A4 | 现有部署 migration：一次性 SQL 把 blanket `PublicReadWrite` 改为 per-object 默认值，带 rollback |
| A5 | User 敏感字段 FLS：`FailedLoginAttempts`/`LockedUntil` 对非 admin 设为 `PermissionsRead=false` |
| A6 | TeamMember 写权限：record owner + Edit access + admin 可以加/删 team member |
| A7 | GetRoleDescendants 缓存：在 `refreshRoleHierarchy` 时预计算 descendant map，O(1) 查找 |
| A8 | SecurityContext 用 explicit values（0/10/20/30）+ named check methods 替代 iota + `>=` |

## 9. 下一步

- [x] 回答 Q1-Q4（高优先级调研问题）
- [x] 写正式的 SECURITY_REFACTOR_DESIGN.md v1
- [x] Post 到 PR #655
- [x] 4-Expert Review
- [x] B1/B2/A1-A8 调研
- [ ] **更新 SECURITY_REFACTOR_DESIGN.md v2**（修正 B1-B3 + 补充 A1-A8）
- [ ] Post v2 到 PR #655
- [ ] 开始实施（按 A2 的 PR 拆分策略）
