Skip to content
bitzorcas
中EN

Concept

RiskControl 租户策略与证据持久化

深入 riskcontrol.policy、区间不变量、默认回退、RiskAssessment 快照、独立事务和 QueryShape Store。

Last updated

风控策略决定“同一组信号如何处置”,评估事实回答“当时为什么这样处置”。前者允许按租户变化,后者必须在策略更新后仍保持历史原貌。

1. 单项 JSON 整体覆盖

RiskControlSettingDefinitions 注册租户级设置 riskcontrol.policy。它不是五个分散阈值,而是一个 JSON 原子值,包含完整 ScoreRanges 和 ChallengeMap。

默认策略设置形状
{
"scoreRanges": {
"Low": { "min": 0, "max": 30 },
"Medium": { "min": 31, "max": 50 },
"High": { "min": 51, "max": 75 },
"Critical": { "min": 76, "max": 90 },
"Block": { "min": 91, "max": 100 }
},
"challengeMap": {
"Low": "None",
"Medium": "Captcha",
"High": "MFA",
"Critical": "StepUp",
"Block": "Block"
}
}

Settings 子系统先解析租户覆盖,再回退全局值和代码默认值;RiskControl 在读取结果之上还执行自己的 JSON 与业务校验。

2. 策略不变量

RiskPolicyConfig.IsValid 和领域 RiskPolicy 都要求:

  • 恰好包含 Low、Medium、High、Critical、Block;
  • 每个 Level 恰好有一个 Challenge;
  • 区间在 0–100 内,Min 不大于 Max;
  • 第一个区间从 0 开始,最后一个到 100;
  • 相邻闭区间满足前 Max + 1 = 后 Min,无空洞、无重叠;
  • Challenge 名称可以解析为已知 SmartEnum。
在保存前校验租户策略
public static class RiskControlErrors
{
public static readonly Error PolicyInvalid =
Error.Validation("RiskControl.Policy.Invalid", "Risk policy is invalid.");
}
var config = JsonSerializer.Deserialize(
command.Json,
RiskControlApplicationJsonSerializerContext.Default.RiskPolicyConfig);
// 保存入口先拒绝非法配置,不能依赖读取时静默回退。
if (config is null || !config.IsValid())
{
return Result.Failure(RiskControlErrors.PolicyInvalid);
}
// 领域构造再做一次不变量校验,防止程序内直接构造绕过。
_ = config.ToPolicy();
return await settings.SetAsync(
SettingsRiskPolicyProvider.PolicyKey,
config.ToJson(),
currentTenant.Tenant.EffectiveTenantId,
cancellationToken);

仓库当前没有 RiskControl 专属策略编辑 Endpoint;实际管理可以复用 Settings 管理能力,但应提供预览、审批和审计,而不是让运营直接编辑无校验 JSON。

3. 读取失败为何回退默认

SettingsRiskPolicyProvider.ResolveAsync 对 Settings Failure、JSON null、非法配置、转换异常都记录 Warning 并返回 RiskPolicy.Default。调用方取消单独传播。

这是一种 fail-safe,而不是 fail-closed:系统仍有一套已知策略,不会完全关闭风险控制。风险在于租户原本配置更严格时,故障会降低到默认阈值。建议记录:

  • tenantId 与策略来源(tenant/global/default/fallback);
  • 当前策略版本或哈希;
  • fallback 原因和持续时间;
  • 严格租户是否要求 fallback → MFA。

4. RiskAssessment 是不可变历史事实

表 SysRiskAssessment 是租户 + 软删除统一聚合,主要列包括:

列语义
TenantId评估归属租户
UserId用户解析前评估时可空
RiskScore0–100 总分
Level当时 Level 名称
Challenge当时 RequiredChallenge
FactorsJson命中因子与 Evidence 的 AOT JSON 快照
AssessedAtUTC 评估时间

Create 深复制 Evidence,防止调用方随后修改字典改写历史。Store 只接受占位 Id "0",再用基础设施 Id Generator 分配最终标识。

Restore 会拒绝未知 Level/Challenge、损坏 JSON、嵌套 Evidence、重复 JSON 属性和权重总和不一致。它不会按新策略重新算旧 Level。

5. 独立事务时序

RiskAssessmentPersistencePipelineBehavior 必须位于 TransactionPipelineBehavior 外层:

Independent UoWLogin handlerBusiness transactionRisk persistence pipelineIndependent UoWLogin handlerBusiness transactionRisk persistence pipelinenextexecutesuccess / failure / exceptioncommit / rollback completesexecutionContext.Take()Begin with CancellationToken.NoneRecord assessmentCommit or rollback

使用 CancellationToken.None 是为了客户端断开后仍尽力保存安全事实。它也意味着数据库故障时操作不受原请求超时约束,底层连接与命令必须有独立超时。

6. “最多一次捕获”不是持久化幂等

RiskAssessmentExecutionContext.Capture 在同一 Scoped 请求已有 pending 时抛错,Take 读取后清空。这能防同一请求内重复记录,却不是跨重试幂等:

  • HTTP 重试会重新评估并生成新 AssessmentId;
  • 消息重放也会生成另一条事实;
  • 持久化提交成功但响应丢失时没有业务 Correlation 唯一键去重。

若运营需要“一次登录尝试一条评估”,模型应保存 AttemptId/CorrelationId,并建立 TenantId + AttemptId 唯一约束。

7. 持久化失败不会改变登录结果

独立 Begin、Record 或 Commit 失败时,Pipeline 尝试 Rollback,记录两类 Error Log,然后吞掉异常。这样原登录成功不会因为审计库抖动变失败,原业务异常也不会被覆盖。

8. QueryShape 读模型

RiskAssessmentStore.SearchAsync:

  • 将 page index 最小化为 1,page size 限制 1–100;
  • 显式 Predicate 包含可信 TenantId、User、Level、From、To;
  • 默认按 AssessedAt desc、AssessmentId desc 稳定排序;
  • 只投影持久化标量,再调用 Restore;
  • 当前 ORM 通过 QueryShape 执行分页,不手写 EF/SqlSugar 分支;
  • 损坏快照返回 RiskControl.Assessment.InvalidPersistenceState。
租户基线不能由查询参数覆盖
private static Expression<Func<RiskAssessment, bool>> BuildPredicate(
string trustedTenantId,
RiskAssessmentFilter filter)
{
var levelName = filter.Level?.Name;
// TenantId 单独来自认证上下文,不属于可选 filter。
// User、Level 与时间范围只在可信租户基线内缩小结果集。
return assessment => assessment.TenantId == trustedTenantId
&& (filter.UserId == null || assessment.UserId == filter.UserId)
&& (levelName == null || assessment.LevelName == levelName)
&& (filter.From == null || assessment.AssessedAt >= filter.From)
&& (filter.To == null || assessment.AssessedAt <= filter.To);
}

9. 隐私与保留

当前 FactorsJson 会存原始 IP、设备指纹、完整业务时间;查询 DTO 又返回完整 Factors。生产需要:

  1. 明确合法处理目的与保留期;
  2. IP 截断/哈希、设备别名化,避免持久化 Raw Fingerprint;
  3. 列表默认返回 Code/Weight,敏感 Evidence 使用更高权限的详情端口;
  4. 导出和审计记录访问原因;
  5. 软删除之外提供真实保留清理与法务 Hold。

10. 测试与审查

Terminal window
# 验证 Pipeline 注册顺序;Risk 必须在 Transaction 之前。
rg -n "RiskAssessmentPersistencePipelineBehavior|TransactionPipelineBehavior" \
src/Hosts/BitzOrcas.Api/Composition/ApiPipelineRegistration.cs
# 审查策略 key、默认值、回退和 JSON 元数据。
rg -n "riskcontrol.policy|RiskPolicyConfig|RiskPolicy.Default|JsonSerializer" \
src/Platform/RiskControl -g '*.cs'
# 检查评估事实是否已增加 Attempt/Correlation 幂等字段;当前没有。
rg -n "AttemptId|CorrelationId|TraceId" \
src/Platform/RiskControl -g '*.cs'

上一篇:Captcha Provider · 下一篇:集成边界

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%