Skip to content
bitzorcas
中EN

Concept

RiskControl tenant policy and evidence persistence

Understand riskcontrol.policy invariants, default fallback, immutable RiskAssessment snapshots, independent transactions, and the QueryShape store.

Last updated

Policy decides how a tenant treats the same signals. Assessment evidence records why a particular decision was made at that time. Policy may change; historical evidence must not be reinterpreted.

1. One atomic JSON override

RiskControlSettingDefinitions contributes tenant setting riskcontrol.policy. One JSON value contains the complete score ranges and challenge map.

Default policy shape
{
"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"
}
}

The Settings subsystem resolves tenant override, global value, and code default. RiskControl then performs JSON and domain validation.

2. Policy invariants

Both config and domain policy require:

  • exactly five known levels and one challenge for each;
  • score ranges inside 0–100 with Min no greater than Max;
  • first range beginning at zero and last ending at one hundred;
  • adjacent closed ranges satisfying previous Max + 1 = next Min;
  • every challenge name resolving to a known strong enum.
Validate before storing tenant policy
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);
// The write path rejects invalid policy instead of relying on read-time fallback.
if (config is null || !config.IsValid())
{
return Result.Failure(RiskControlErrors.PolicyInvalid);
}
// Domain construction provides a second invariant gate for programmatic callers.
_ = config.ToPolicy();
return await settings.SetAsync(
SettingsRiskPolicyProvider.PolicyKey,
config.ToJson(),
currentTenant.Tenant.EffectiveTenantId,
cancellationToken);

There is no RiskControl-specific policy-management endpoint. Settings administration can store the value, but a commercial surface should add preview, approval, and audit instead of raw JSON editing.

3. Default fallback

SettingsRiskPolicyProvider.ResolveAsync warns and returns RiskPolicy.Default on Settings failure, JSON null, invalid config, or conversion exception. Caller cancellation propagates.

This preserves a known control rather than disabling risk assessment. It can still weaken a tenant whose override was stricter. Production telemetry should include policy source, version or hash, fallback reason, and strict-tenant fallback behavior.

4. Immutable assessment fact

SysRiskAssessment is a tenant-owned, soft-deleted unified aggregate:

ColumnMeaning
TenantIdassessment owner
UserIdnullable before user resolution
RiskScoretotal from 0 to 100
Levellevel at evaluation time
Challengerequired challenge at evaluation time
FactorsJsonAOT snapshot of factors and evidence
AssessedAtUTC evaluation time

Create deep-copies evidence and uses placeholder Id "0". The Store assigns the infrastructure Id. Restore rejects unknown enums, malformed or ambiguous JSON, nested evidence, and factor totals inconsistent with RiskScore.

5. Independent transaction

Risk persistence must wrap the business transaction:

Independent UoWLogin handlerBusiness transactionRisk persistence pipelineIndependent UoWLogin handlerBusiness transactionRisk persistence pipelinenextexecuteresult or exceptioncommit or rollback completestake pending assessmentbegin without request cancellationrecord and commit

CancellationToken.None lets evidence survive client disconnect. Database connection and command timeouts must still bound the independent operation.

6. Capture-once is not durable idempotency

RiskAssessmentExecutionContext rejects a second pending assessment in one scoped request and clears on Take. It does not deduplicate HTTP retry, message replay, or a commit whose response is lost.

If one login attempt must produce one fact, add AttemptId or CorrelationId and a TenantId + AttemptId unique constraint.

7. Persistence failure

Begin, Record, or Commit failure triggers rollback and error logging, then is swallowed so the original login result or exception survives.

8. QueryShape store

RiskAssessmentStore.SearchAsync normalizes page index, clamps size to 1–100, applies an explicit tenant/user/level/time predicate, orders by AssessedAt and AssessmentId descending, projects scalar columns, and restores validated aggregates. QueryShape keeps the implementation provider-neutral.

Keep tenant ownership outside the optional filter
private static Expression<Func<RiskAssessment, bool>> BuildPredicate(
string trustedTenantId,
RiskAssessmentFilter filter)
{
var levelName = filter.Level?.Name;
// TenantId comes from trusted runtime context and cannot be overridden by query input.
// User, level, and time remain optional business filters inside that tenant baseline.
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);
}

Damaged snapshots return RiskControl.Assessment.InvalidPersistenceState instead of silently dropping a row.

9. Privacy and retention

Current FactorsJson contains raw IP, device fingerprint, and full business time, and the query DTO returns all factors. Production needs:

  1. a documented purpose and retention period;
  2. IP truncation or hashing and device pseudonymization;
  3. factor-code summaries by default and a separately authorized evidence endpoint;
  4. purpose/audit controls for export;
  5. retention deletion and legal hold beyond soft delete.

10. Review commands

Terminal window
# Risk persistence must appear before the transaction behavior.
rg -n "RiskAssessmentPersistencePipelineBehavior|TransactionPipelineBehavior" \
src/Hosts/BitzOrcas.Api/Composition/ApiPipelineRegistration.cs
# Review policy key, default, fallback, and source-generated JSON.
rg -n "riskcontrol.policy|RiskPolicyConfig|RiskPolicy.Default|JsonSerializer" \
src/Platform/RiskControl -g '*.cs'
# The current model has no attempt/correlation idempotency field.
rg -n "AttemptId|CorrelationId|TraceId" \
src/Platform/RiskControl -g '*.cs'

Back: Captcha providers · Next: Integration boundaries

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%