Skip to content

Commit 2475cd7

Browse files
committed
Make access rule conditions evaluate themselves
Move per-condition evaluation onto each AccessCondition via an abstract Evaluate(AccessSignals). The engine is now only the combiner: it folds the per-condition results (deny > approval > allow, empty allows, null entry fails closed) and no longer knows how any kind decides. Validate conditions through an exhaustive IAccessConditionVisitor rather than a type switch, so adding a kind fails to compile until validation handles it. UnsupportedCondition consequently only guards a null entry now — an unknown kind is rejected at the JSON layer and can't reach evaluation. Also document each condition's JSON wire format.
1 parent 4a0d7be commit 2475cd7

9 files changed

Lines changed: 200 additions & 137 deletions

File tree

bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ public enum DenyReason
3030
/// <summary>The timezone was unknown/invalid, or the instant fell outside every configured window.</summary>
3131
NotWithinTimeWindow,
3232

33-
/// <summary>The engine did not recognize the condition kind, so it could not be shown satisfied (fail closed).</summary>
33+
/// <summary>
34+
/// A condition entry could not be evaluated — in practice a null entry from a malformed stored document — so it
35+
/// fails closed. A genuinely unknown <c>kind</c> cannot reach here: the JSON layer rejects unknown kinds and the
36+
/// visitor dispatch is exhaustive at compile time.
37+
/// </summary>
3438
UnsupportedCondition,
3539
}
3640

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,19 @@
1-
using System.Globalization;
2-
using System.Net;
3-
using Bit.Services.Pam.Models.Conditions;
1+
using Bit.Services.Pam.Models.Conditions;
42

53
namespace Bit.Services.Pam.Engine;
64

75
/// <summary>
8-
/// Evaluates the access rule's flat list of <see cref="AccessCondition"/>s against the caller's signals. Each
9-
/// condition yields an <see cref="AccessEvaluation"/>; the results combine with deny taking precedence over a
10-
/// pending approval, which in turn takes precedence over allow. An empty list is vacuously satisfied (allow).
11-
/// Unparseable inputs fail closed before they reach the engine.
6+
/// Combines the results of an access rule's flat list of <see cref="AccessCondition"/>s into one decision. Each
7+
/// condition evaluates itself (<see cref="AccessCondition.Evaluate"/>); the engine only folds those results, with
8+
/// deny taking precedence over a pending approval, which in turn takes precedence over allow. An empty list is
9+
/// vacuously satisfied (allow). Unparseable inputs fail closed before they reach the engine.
1210
/// </summary>
1311
public sealed class AccessRuleEngine : IAccessRuleEngine
1412
{
1513
public AccessEvaluation Evaluate(IReadOnlyList<AccessCondition> conditions, AccessSignals signals) =>
16-
AccessEvaluation.Combine(conditions.Select(condition => EvaluateCondition(condition, signals)));
14+
AccessEvaluation.Combine(conditions.Select(condition => EvaluateOne(condition, signals)));
1715

18-
private static AccessEvaluation EvaluateCondition(AccessCondition condition, AccessSignals signals) => condition switch
19-
{
20-
HumanApprovalCondition => AccessEvaluation.RequiresApproval,
21-
IpAllowlistCondition ip => EvaluateIpAllowlist(ip, signals),
22-
TimeOfDayCondition time => EvaluateTimeOfDay(time, signals),
23-
// A condition kind the engine does not understand cannot be shown to be satisfied, so deny.
24-
_ => AccessEvaluation.Deny(DenyReason.UnsupportedCondition),
25-
};
26-
27-
private static AccessEvaluation EvaluateIpAllowlist(IpAllowlistCondition condition, AccessSignals signals)
28-
{
29-
// An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed.
30-
if (condition.Cidrs.Count == 0 || signals.IpAddress is null)
31-
{
32-
return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
33-
}
34-
35-
foreach (var cidr in condition.Cidrs)
36-
{
37-
if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress))
38-
{
39-
return AccessEvaluation.Allow;
40-
}
41-
}
42-
43-
return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
44-
}
45-
46-
private static AccessEvaluation EvaluateTimeOfDay(TimeOfDayCondition condition, AccessSignals signals)
47-
{
48-
if (!TimeZoneInfo.TryFindSystemTimeZoneById(condition.Tz, out var timeZone))
49-
{
50-
// The window cannot be evaluated without a valid timezone, so fail closed.
51-
return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
52-
}
53-
54-
var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone);
55-
var day = local.DayOfWeek;
56-
var time = TimeOnly.FromTimeSpan(local.TimeOfDay);
57-
58-
foreach (var window in condition.Windows)
59-
{
60-
if (WindowContains(window, day, time))
61-
{
62-
return AccessEvaluation.Allow;
63-
}
64-
}
65-
66-
return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
67-
}
68-
69-
private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time)
70-
{
71-
// AccessWeekday values align with System.DayOfWeek (Sunday = 0), so a direct cast compares correctly.
72-
var dayMatches = window.Days.Any(d => (DayOfWeek)d == day);
73-
if (!dayMatches)
74-
{
75-
return false;
76-
}
77-
78-
return TimeOnly.TryParseExact(window.From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from)
79-
&& TimeOnly.TryParseExact(window.To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)
80-
&& time >= from && time <= to;
81-
}
16+
private static AccessEvaluation EvaluateOne(AccessCondition? condition, AccessSignals signals) =>
17+
// A null entry (malformed stored conditions) cannot be evaluated, so fail closed.
18+
condition is null ? AccessEvaluation.Deny(DenyReason.UnsupportedCondition) : condition.Evaluate(signals);
8219
}

bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json.Serialization;
2+
using Bit.Services.Pam.Engine;
23

34
namespace Bit.Services.Pam.Models.Conditions;
45

@@ -10,4 +11,19 @@ namespace Bit.Services.Pam.Models.Conditions;
1011
[JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")]
1112
[JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")]
1213
[JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")]
13-
public abstract class AccessCondition;
14+
public abstract class AccessCondition
15+
{
16+
/// <summary>
17+
/// Evaluates this condition against the request-time <paramref name="signals"/>, returning whether it allows,
18+
/// denies, or requires human approval. The engine folds each condition's result into the rule's overall
19+
/// decision; it does not know how any individual kind decides.
20+
/// </summary>
21+
public abstract AccessEvaluation Evaluate(AccessSignals signals);
22+
23+
/// <summary>
24+
/// Double-dispatches to the <paramref name="visitor"/>'s method for this condition's concrete kind, used by the
25+
/// validator to check each kind is well-formed. The compiler requires the visitor to handle every kind, so a
26+
/// newly added condition can't be silently skipped by validation.
27+
/// </summary>
28+
public abstract T Accept<T>(IAccessConditionVisitor<T> visitor);
29+
}
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
namespace Bit.Services.Pam.Models.Conditions;
1+
using Bit.Services.Pam.Engine;
2+
3+
namespace Bit.Services.Pam.Models.Conditions;
24

35
/// <summary>
46
/// Always requires a human decision before a lease can be issued.
57
/// </summary>
6-
public sealed class HumanApprovalCondition : AccessCondition;
8+
/// <remarks>Wire format: <c>{ "kind": "human_approval" }</c></remarks>
9+
public sealed class HumanApprovalCondition : AccessCondition
10+
{
11+
public override AccessEvaluation Evaluate(AccessSignals signals) => AccessEvaluation.RequiresApproval;
12+
13+
public override T Accept<T>(IAccessConditionVisitor<T> visitor) => visitor.VisitHumanApproval(this);
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace Bit.Services.Pam.Models.Conditions;
2+
3+
/// <summary>
4+
/// A type-safe operation over the closed set of <see cref="AccessCondition"/> kinds, dispatched via
5+
/// <see cref="AccessCondition.Accept{T}"/>. Implemented once per operation — the engine evaluates a condition, the
6+
/// validator checks it is well-formed — with <typeparamref name="T"/> as that operation's result. Because the
7+
/// interface has one method per kind, the compiler forces every implementation to handle every kind, so a newly
8+
/// added condition can never be silently skipped by evaluation or validation.
9+
/// </summary>
10+
public interface IAccessConditionVisitor<out T>
11+
{
12+
T VisitHumanApproval(HumanApprovalCondition condition);
13+
T VisitIpAllowlist(IpAllowlistCondition condition);
14+
T VisitTimeOfDay(TimeOfDayCondition condition);
15+
}
Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,43 @@
1-
namespace Bit.Services.Pam.Models.Conditions;
1+
using System.Net;
2+
using Bit.Services.Pam.Engine;
3+
4+
namespace Bit.Services.Pam.Models.Conditions;
25

36
/// <summary>
47
/// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies.
58
/// </summary>
9+
/// <remarks>
10+
/// Wire format:
11+
/// <code>
12+
/// { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8", "2001:db8::/32"] }
13+
/// </code>
14+
/// </remarks>
615
public sealed class IpAllowlistCondition : AccessCondition
716
{
817
/// <summary>
918
/// The allowed source ranges in CIDR notation (e.g. <c>"10.0.0.0/8"</c>). The condition allows when the caller's
1019
/// IP is in any one of them. At least one required, and each must parse; an empty list denies.
1120
/// </summary>
1221
public IReadOnlyList<string> Cidrs { get; init; } = [];
22+
23+
public override AccessEvaluation Evaluate(AccessSignals signals)
24+
{
25+
// An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed.
26+
if (Cidrs.Count == 0 || signals.IpAddress is null)
27+
{
28+
return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
29+
}
30+
31+
foreach (var cidr in Cidrs)
32+
{
33+
if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress))
34+
{
35+
return AccessEvaluation.Allow;
36+
}
37+
}
38+
39+
return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
40+
}
41+
42+
public override T Accept<T>(IAccessConditionVisitor<T> visitor) => visitor.VisitIpAllowlist(this);
1343
}

bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,47 @@
1-
using Bit.Services.Pam.Enums;
1+
using System.Globalization;
2+
using Bit.Services.Pam.Engine;
3+
using Bit.Services.Pam.Enums;
4+
25
namespace Bit.Services.Pam.Models.Conditions;
36

47
/// <summary>
58
/// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in
69
/// the named IANA timezone; otherwise denies.
710
/// </summary>
11+
/// <remarks>
12+
/// Wire format:
13+
/// <code>
14+
/// {
15+
/// "kind": "time_of_day",
16+
/// "tz": "America/New_York",
17+
/// "windows": [{ "days": ["mon", "tue", "wed", "thu", "fri"], "from": "09:00", "to": "17:00" }]
18+
/// }
19+
/// </code>
20+
/// </remarks>
821
public sealed class TimeOfDayCondition : AccessCondition
922
{
1023
/// <summary>The IANA timezone (e.g. <c>"America/New_York"</c>) the windows are evaluated in. An unknown or invalid zone denies.</summary>
1124
public string Tz { get; init; } = string.Empty;
1225

1326
/// <summary>The windows that grant access; the condition allows when the instant falls in any one of them. At least one required.</summary>
1427
public IReadOnlyList<TimeWindow> Windows { get; init; } = [];
28+
29+
public override AccessEvaluation Evaluate(AccessSignals signals)
30+
{
31+
if (!TimeZoneInfo.TryFindSystemTimeZoneById(Tz, out var timeZone))
32+
{
33+
// The window cannot be evaluated without a valid timezone, so fail closed.
34+
return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
35+
}
36+
37+
var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone);
38+
var day = local.DayOfWeek;
39+
var time = TimeOnly.FromTimeSpan(local.TimeOfDay);
40+
41+
return Windows.Any(window => window.Contains(day, time)) ? AccessEvaluation.Allow : AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
42+
}
43+
44+
public override T Accept<T>(IAccessConditionVisitor<T> visitor) => visitor.VisitTimeOfDay(this);
1545
}
1646

1747
/// <summary>
@@ -28,4 +58,21 @@ public sealed class TimeWindow
2858

2959
/// <summary>Window end, 24-hour <c>HH:mm</c> (<c>00:00</c>–<c>23:59</c>). Inclusive.</summary>
3060
public string To { get; init; } = string.Empty;
61+
62+
/// <summary>
63+
/// Whether this window admits the given <paramref name="day"/> and <paramref name="time"/> (both already in the
64+
/// parent condition's timezone). Unparseable bounds admit nothing, so the window fails closed.
65+
/// </summary>
66+
public bool Contains(DayOfWeek day, TimeOnly time)
67+
{
68+
// AccessWeekday values align with System.DayOfWeek (Sunday = 0), so a direct cast compares correctly.
69+
if (Days.All(d => (DayOfWeek)d != day))
70+
{
71+
return false;
72+
}
73+
74+
return TimeOnly.TryParseExact(From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from)
75+
&& TimeOnly.TryParseExact(To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)
76+
&& time >= from && time <= to;
77+
}
3178
}

0 commit comments

Comments
 (0)