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.
History is explicitly filtered by TenantId and UserName, sorted by time and Id, and limited to fifty rows.
2. Execution sequence
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
| Combination | Score | Default result |
|---|---|---|
| no factors | 0 | Low / None |
| NewIp | 20 | Low / None |
| NewIp + NewDevice | 35 | Medium / Captcha |
| HighFrequency | 40 | Medium / Captcha |
| HighFrequency + NewIp | 60 | High / MFA |
| HighFrequency + NewIp + NewDevice | 75 | High / MFA |
| all four | 85 | Critical / 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.
[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
- Treat the shared snapshot as read-only.
- Propagate caller cancellation and bound external I/O.
- Return null for no match; do not emit zero-weight placeholders.
- Keep evidence to privacy-reviewed scalar strings.
- Recalculate every possible score so the 0–100 invariant remains valid.
- 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
# 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