Skip to content
bitzorcas
中EN

Concept

RiskControl rule engine and risk factors

Examine the login-history snapshot, four built-in factors, parallel execution, score reachability, extension contracts, and failure behavior.

Last updated

RuleBasedRiskEngine is a deterministic orchestrator. It loads history and tenant policy once, evaluates every IRiskFactor in parallel, sums matched weights, and maps the score to a level and challenge.

1. Input is not a complete user

LoginRiskContext contains TenantId, nullable UserId, UserName, IP, UserAgent, and DeviceInfo. Login calls the engine before user lookup, so UserId is null. That avoids revealing whether the user exists, but it also prevents LoginHistoryProvider from loading long-lived UserDevice rows during this evaluation.

LoginRiskContext

LoginHistoryProvider

30-minute / 50-row snapshot

Known IPs

Known devices

Five-minute failures

Last successful time

Shared by all factors

History is explicitly filtered by TenantId and UserName, sorted by time and Id, and limited to fifty rows.

2. Execution sequence

Actual rule-engine orchestration
var snapshot = await history.GetSnapshotAsync(
context.TenantId,
context.UserName,
context.UserId,
cancellationToken);
var policy = await policyProvider.ResolveAsync(context.TenantId, cancellationToken);
// Factors share one immutable snapshot; one exception fails Task.WhenAll.
var tasks = factors.Select(factor =>
factor.EvaluateAsync(context, snapshot, cancellationToken).AsTask());
var results = await Task.WhenAll(tasks);
// Only matched factors are summed; aggregate creation enforces the 0–100 domain.
var assessment = RiskAssessment.Create(
context.TenantId,
context.UserId,
results.OfType<RiskFactor>(),
policy,
clock);
// Scoped context accepts one pending assessment for the outer persistence pipeline.
executionContext.Capture(assessment);

Evaluation does not short-circuit. Even when HighFrequency already implies Captcha, the other factors still run and contribute evidence.

3. Built-in factors

NewIp: 20

An empty IP does not match. When the known set is empty, any non-empty address is new. The known set covers only the same tenant and username within the thirty-minute, fifty-row snapshot.

This is not a long-lived IP reputation model. A previously used address can become new after the window, and incorrect forwarded-header configuration can make every request appear to come from one proxy.

NewDevice: 15

DeviceInfo is preferred and UserAgent is the fallback. A non-empty value absent from known devices matches. Because pre-password Login has no UserId, persistent UserDevice records are not loaded on this path.

UserAgent is not a stable fingerprint. Browser updates, privacy features, and automation produce false new-device signals.

HighFrequency: 40

Three or more failures in five minutes match. This factor alone reaches Medium/Captcha under the default policy.

The count comes from persisted LoginLog, not an atomic rate limiter. Concurrent failures can all read an earlier count before new logs commit. Rate limiting remains a separate control.

OffHours: 10

IAppClock.Now supplies business-local time. Hours 00:00 inclusive through 06:00 exclusive match. The interval is hard-coded and is not tenant policy.

A shared host timezone can misclassify global tenants. A future factor should resolve a validated tenant timezone and working-hours policy.

4. Reachable combinations

CombinationScoreDefault result
no factors0Low / None
NewIp20Low / None
NewIp + NewDevice35Medium / Captcha
HighFrequency40Medium / Captcha
HighFrequency + NewIp60High / MFA
HighFrequency + NewIp + NewDevice75High / MFA
all four85Critical / StepUp

Block begins at 91 and is unreachable with built-in factors. A tenant can lower its Block threshold or an extension can add a factor while keeping every possible total at or below 100.

5. Assessment invariants

RiskAssessment.Create rejects a blank tenant, deep-copies factor evidence, rejects blank audit labels or negative weights, validates the total, stores strong-enum names plus AOT JSON, and uses placeholder Id "0".

Restore validates the persisted level, challenge, JSON shape, factors, and score sum. It does not recompute an old decision with a new policy.

Add an explainable external factor
[RegisterTransient<IRiskFactor>]
public sealed class ImpossibleTravelFactor(
IGeoVelocityPort geoVelocity): IRiskFactor
{
public async ValueTask<RiskFactor?> EvaluateAsync(
LoginRiskContext context,
LoginHistorySnapshot snapshot,
CancellationToken cancellationToken)
{
// Insufficient evidence is no match, not an invented high-risk signal.
if (context.IpAddress is null || snapshot.LastSuccessfulTime is null)
return null;
var signal = await geoVelocity.EvaluateAsync(
context.IpAddress,
snapshot.LastSuccessfulTime.Value,
cancellationToken);
if (!signal.IsImpossible)
return null;
// Keep stable scalar evidence instead of a complete location history.
return new RiskFactor(
"ImpossibleTravel",
"Travel velocity exceeds policy",
25m,
new Dictionary<string, string?>
{
["distanceBand"] = signal.DistanceBand,
["elapsedMinutes"] = signal.ElapsedMinutes.ToString(CultureInfo.InvariantCulture),
});
}
}

6. Extension rules

  1. Treat the shared snapshot as read-only.
  2. Propagate caller cancellation and bound external I/O.
  3. Return null for no match; do not emit zero-weight placeholders.
  4. Keep evidence to privacy-reviewed scalar strings.
  5. Recalculate every possible score so the 0–100 invariant remains valid.
  6. Assume any factor exception fails the entire assessment.

7. Failure semantics

Except for caller cancellation, AssessAsync catches all exceptions, logs, returns RiskControl.Assessment.Failed, and captures no partial assessment. LoginFlow then chooses to warn and continue with no risk challenge.

8. Essential tests

  • One history read serves every factor.
  • Parallel completion order does not change the result.
  • Scores 30/31, 50/51, 75/76, and 90/91 map correctly.
  • All sixteen built-in factor combinations are explicit.
  • A factor exception returns stable failure and leaves execution context empty.
  • Cancellation propagates.
  • IP and device behavior is covered at empty-history and time-window boundaries.
  • Evidence deep snapshot, AOT round-trip, and privacy restrictions are enforced.

9. Source review

Terminal window
# List registered factors and weights, then recalculate the maximum reachable score.
rg -n "RegisterTransient<IRiskFactor>|const decimal Weight|FailureThreshold" \
src/Platform/RiskControl -g '*.cs'
# Review history windows, tenant predicates, and device sources.
rg -n "RecentWindow|FailureWindow|MaxRecentLogs|KnownDevices|TenantId ==" \
src/Platform/Identity/BitzOrcas.Identity.Infrastructure/Identity/LoginHistoryProvider.cs

Back: RiskControl · Next: Captcha providers

100%

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