|
1 | 1 | using System.Net; |
2 | 2 | using Bit.Services.Pam.Engine; |
3 | | -using Bit.Services.Pam.Enums; |
4 | 3 | using Bit.Services.Pam.Models.Conditions; |
5 | 4 | using Xunit; |
6 | 5 |
|
7 | 6 | namespace Bit.Services.Pam.Test.Engine; |
8 | 7 |
|
9 | 8 | public class AccessRuleEngineTests |
10 | 9 | { |
11 | | - // 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC. |
12 | | - private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero); |
13 | | - |
14 | 10 | private readonly AccessRuleEngine _sut = new(); |
15 | 11 |
|
16 | | - private static AccessSignals Signals(IPAddress? ip = null, DateTimeOffset? at = null) => new() |
| 12 | + private static AccessSignals Signals() => new() |
17 | 13 | { |
18 | | - IpAddress = ip, |
19 | | - Timestamp = at ?? _now, |
| 14 | + IpAddress = IPAddress.Parse("10.1.2.3"), |
| 15 | + Timestamp = new DateTimeOffset(2026, 6, 4, 12, 0, 0, TimeSpan.Zero), |
20 | 16 | }; |
21 | 17 |
|
22 | | - private static AccessCondition[] Set(params AccessCondition[] conditions) => conditions; |
23 | | - |
24 | | - [Fact] |
25 | | - public void Evaluate_HumanApproval_RequiresApproval() |
26 | | - { |
27 | | - var evaluation = _sut.Evaluate(Set(new HumanApprovalCondition()), Signals()); |
28 | | - |
29 | | - Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); |
30 | | - } |
31 | | - |
32 | | - [Fact] |
33 | | - public void Evaluate_IpAllowlist_IpInRange_Allows() |
34 | | - { |
35 | | - var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); |
36 | | - |
37 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
38 | | - |
39 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
40 | | - } |
41 | | - |
42 | | - [Fact] |
43 | | - public void Evaluate_IpAllowlist_IpOutOfRange_Denies() |
44 | | - { |
45 | | - var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); |
46 | | - |
47 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1"))); |
48 | | - |
49 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
50 | | - Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); |
51 | | - } |
52 | | - |
53 | | - [Fact] |
54 | | - public void Evaluate_IpAllowlist_UnknownIp_DeniesClosed() |
55 | | - { |
56 | | - var conditions = Set(new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); |
57 | | - |
58 | | - var evaluation = _sut.Evaluate(conditions, Signals(ip: null)); |
59 | | - |
60 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
61 | | - Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); |
62 | | - } |
63 | | - |
64 | | - [Fact] |
65 | | - public void Evaluate_IpAllowlist_NoEntries_DeniesClosed() |
66 | | - { |
67 | | - var evaluation = _sut.Evaluate(Set(new IpAllowlistCondition()), Signals(IPAddress.Parse("10.1.2.3"))); |
68 | | - |
69 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
70 | | - Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); |
71 | | - } |
72 | | - |
73 | | - [Fact] |
74 | | - public void Evaluate_TimeOfDay_WithinWindow_Allows() |
75 | | - { |
76 | | - var conditions = Set(new TimeOfDayCondition |
77 | | - { |
78 | | - Tz = "UTC", |
79 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }], |
80 | | - }); |
81 | | - |
82 | | - var evaluation = _sut.Evaluate(conditions, Signals()); |
83 | | - |
84 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
85 | | - } |
86 | | - |
87 | | - [Fact] |
88 | | - public void Evaluate_TimeOfDay_OutsideTimeWindow_Denies() |
89 | | - { |
90 | | - var conditions = Set(new TimeOfDayCondition |
91 | | - { |
92 | | - Tz = "UTC", |
93 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }], |
94 | | - }); |
95 | | - |
96 | | - var evaluation = _sut.Evaluate(conditions, Signals()); |
97 | | - |
98 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
99 | | - Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); |
100 | | - } |
101 | | - |
102 | 18 | [Fact] |
103 | | - public void Evaluate_TimeOfDay_DayNotListed_Denies() |
| 19 | + public void Evaluate_NoConditions_Allows() |
104 | 20 | { |
105 | | - var conditions = Set(new TimeOfDayCondition |
106 | | - { |
107 | | - Tz = "UTC", |
108 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Fri], From = "00:00", To = "23:59" }], |
109 | | - }); |
110 | | - |
111 | | - var evaluation = _sut.Evaluate(conditions, Signals()); |
112 | | - |
113 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
114 | | - Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); |
| 21 | + // A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through |
| 22 | + // PAM for audit logging. |
| 23 | + Assert.Equal(AccessEvaluationOutcome.Allow, _sut.Evaluate([], Signals()).Outcome); |
115 | 24 | } |
116 | 25 |
|
117 | 26 | [Fact] |
118 | | - public void Evaluate_TimeOfDay_EvaluatesInConfiguredTimezone() |
| 27 | + public void Evaluate_DefersToEachConditionsOwnResult() |
119 | 28 | { |
120 | | - // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window. |
121 | | - var conditions = Set(new TimeOfDayCondition |
122 | | - { |
123 | | - Tz = "America/New_York", |
124 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "18:00", To = "20:00" }], |
125 | | - }); |
126 | | - |
127 | | - var evaluation = _sut.Evaluate(conditions, Signals(at: new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero))); |
128 | | - |
129 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
| 29 | + // The engine does not decide anything itself; it returns what the condition's Evaluate reports. |
| 30 | + Assert.Equal(AccessEvaluationOutcome.RequiresApproval, |
| 31 | + _sut.Evaluate([new StubCondition(AccessEvaluation.RequiresApproval)], Signals()).Outcome); |
130 | 32 | } |
131 | 33 |
|
132 | 34 | [Fact] |
133 | | - public void Evaluate_TimeOfDay_UnknownTimezone_DeniesClosed() |
| 35 | + public void Evaluate_CombinesConditionResults_DenyWins() |
134 | 36 | { |
135 | | - var conditions = Set(new TimeOfDayCondition |
| 37 | + // Folding is delegated to AccessEvaluation.Combine (deny > approval > allow); a single denying condition |
| 38 | + // drives the whole rule to deny. The full precedence matrix is covered in AccessEvaluationTests. |
| 39 | + var conditions = new AccessCondition[] |
136 | 40 | { |
137 | | - Tz = "Not/AZone", |
138 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "23:59" }], |
139 | | - }); |
| 41 | + new StubCondition(AccessEvaluation.Allow), |
| 42 | + new StubCondition(AccessEvaluation.Deny(DenyReason.NotWithinIpRange)), |
| 43 | + }; |
140 | 44 |
|
141 | 45 | var evaluation = _sut.Evaluate(conditions, Signals()); |
142 | 46 |
|
143 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
144 | | - Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); |
145 | | - } |
146 | | - |
147 | | - [Fact] |
148 | | - public void Evaluate_AllConditionsAllow_Allows() |
149 | | - { |
150 | | - var conditions = Set( |
151 | | - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, |
152 | | - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }] }); |
153 | | - |
154 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
155 | | - |
156 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
157 | | - } |
158 | | - |
159 | | - [Fact] |
160 | | - public void Evaluate_OneConditionDenies_DeniesWithThatReason() |
161 | | - { |
162 | | - var conditions = Set( |
163 | | - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, |
164 | | - new TimeOfDayCondition { Tz = "UTC", Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }] }); |
165 | | - |
166 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
167 | | - |
168 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
169 | | - Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); |
170 | | - } |
171 | | - |
172 | | - [Fact] |
173 | | - public void Evaluate_AllowPlusHumanApproval_RequiresApproval() |
174 | | - { |
175 | | - var conditions = Set( |
176 | | - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }, |
177 | | - new HumanApprovalCondition()); |
178 | | - |
179 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
180 | | - |
181 | | - Assert.Equal(AccessEvaluationOutcome.RequiresApproval, evaluation.Outcome); |
182 | | - } |
183 | | - |
184 | | - [Fact] |
185 | | - public void Evaluate_DenyOutranksApproval() |
186 | | - { |
187 | | - // A denying condition beats a pending approval: there is nothing to approve if access is barred outright. |
188 | | - var conditions = Set( |
189 | | - new HumanApprovalCondition(), |
190 | | - new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] }); |
191 | | - |
192 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("192.168.1.1"))); |
193 | | - |
194 | 47 | Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
195 | 48 | Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); |
196 | 49 | } |
197 | 50 |
|
198 | 51 | [Fact] |
199 | | - public void Evaluate_NoConditions_Allows() |
| 52 | + public void Evaluate_ForwardsSignalsToConditions() |
200 | 53 | { |
201 | | - // A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through |
202 | | - // PAM for audit logging. |
203 | | - var evaluation = _sut.Evaluate(Set(), Signals()); |
| 54 | + var signals = Signals(); |
| 55 | + var condition = new StubCondition(AccessEvaluation.Allow); |
| 56 | + |
| 57 | + _sut.Evaluate([condition], signals); |
204 | 58 |
|
205 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
| 59 | + Assert.Same(signals, condition.ReceivedSignals); |
206 | 60 | } |
207 | 61 |
|
208 | 62 | [Fact] |
209 | 63 | public void Evaluate_NullConditionEntry_DeniesClosed() |
210 | 64 | { |
211 | 65 | // A null entry (only reachable from a malformed stored document) cannot be evaluated, so it fails closed. |
212 | | - // An unknown condition kind can no longer reach the engine: visitor dispatch is exhaustive at compile time. |
| 66 | + // An unknown condition kind can no longer reach the engine: it is rejected at JSON deserialization. |
213 | 67 | var evaluation = _sut.Evaluate([null!], Signals()); |
214 | 68 |
|
215 | 69 | Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
216 | 70 | Assert.Equal(DenyReason.UnsupportedCondition, evaluation.Reason); |
217 | 71 | } |
218 | 72 |
|
219 | | - [Fact] |
220 | | - public void Evaluate_IpAllowlist_MalformedCidr_DeniesClosed() |
| 73 | + /// <summary>A condition with a fixed result, isolating the engine's folding from any real condition logic.</summary> |
| 74 | + private sealed class StubCondition(AccessEvaluation result) : AccessCondition |
221 | 75 | { |
222 | | - // A present-but-unparseable CIDR matches no address, so a caller with a known IP still fails closed. |
223 | | - var conditions = Set(new IpAllowlistCondition { Cidrs = ["not-a-cidr"] }); |
224 | | - |
225 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
| 76 | + public AccessSignals? ReceivedSignals { get; private set; } |
226 | 77 |
|
227 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
228 | | - Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason); |
229 | | - } |
230 | | - |
231 | | - [Fact] |
232 | | - public void Evaluate_IpAllowlist_LaterCidrMatches_Allows() |
233 | | - { |
234 | | - // The caller matches the second entry, so evaluation must not stop at the first non-matching CIDR. |
235 | | - var conditions = Set(new IpAllowlistCondition { Cidrs = ["192.168.0.0/16", "10.0.0.0/8"] }); |
236 | | - |
237 | | - var evaluation = _sut.Evaluate(conditions, Signals(IPAddress.Parse("10.1.2.3"))); |
238 | | - |
239 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
240 | | - } |
241 | | - |
242 | | - [Fact] |
243 | | - public void Evaluate_TimeOfDay_MalformedWindowTime_DeniesClosed() |
244 | | - { |
245 | | - // The day matches but the window's bounds are unparseable, so the window cannot admit the caller. |
246 | | - var conditions = Set(new TimeOfDayCondition |
247 | | - { |
248 | | - Tz = "UTC", |
249 | | - Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "25:99", To = "30:00" }], |
250 | | - }); |
251 | | - |
252 | | - var evaluation = _sut.Evaluate(conditions, Signals()); |
253 | | - |
254 | | - Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome); |
255 | | - Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason); |
256 | | - } |
257 | | - |
258 | | - [Fact] |
259 | | - public void Evaluate_TimeOfDay_LaterWindowMatches_Allows() |
260 | | - { |
261 | | - // The first window is the wrong day; the second admits the caller, so windows past the first must be checked. |
262 | | - var conditions = Set(new TimeOfDayCondition |
| 78 | + public override AccessEvaluation Evaluate(AccessSignals signals) |
263 | 79 | { |
264 | | - Tz = "UTC", |
265 | | - Windows = |
266 | | - [ |
267 | | - new TimeWindow { Days = [AccessWeekday.Fri], From = "09:00", To = "17:00" }, |
268 | | - new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }, |
269 | | - ], |
270 | | - }); |
271 | | - |
272 | | - var evaluation = _sut.Evaluate(conditions, Signals()); |
| 80 | + ReceivedSignals = signals; |
| 81 | + return result; |
| 82 | + } |
273 | 83 |
|
274 | | - Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome); |
| 84 | + public override T Accept<T>(IAccessConditionVisitor<T> visitor) => throw new NotSupportedException(); |
275 | 85 | } |
276 | 86 | } |
0 commit comments