diff --git a/bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs b/bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs
new file mode 100644
index 000000000000..af008ffebe7b
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs
@@ -0,0 +1,89 @@
+namespace Bit.Services.Pam.Engine;
+
+///
+/// The result of evaluating a single access condition, or the combined result of a rule's whole condition list.
+///
+public enum AccessEvaluationOutcome
+{
+ /// Access is granted automatically, with no human decision required.
+ Allow,
+
+ /// A human decision is required before a lease may be issued.
+ RequiresApproval,
+
+ /// Access is refused; the accompanying records why.
+ Deny,
+}
+
+///
+/// Why an evaluation denied. Carried on a deny ; is the default
+/// for any non-deny outcome.
+///
+public enum DenyReason
+{
+ /// Not a denial (the outcome is allow or requires-approval).
+ None = 0,
+
+ /// The caller's IP was absent, the allowlist was empty, or the IP fell outside every listed CIDR.
+ NotWithinIpRange,
+
+ /// The timezone was unknown/invalid, or the instant fell outside every configured window.
+ NotWithinTimeWindow,
+
+ ///
+ /// A condition entry could not be evaluated — in practice a null entry from a malformed stored document — so it
+ /// fails closed. A genuinely unknown kind cannot reach here: the JSON layer rejects unknown kinds and the
+ /// visitor dispatch is exhaustive at compile time.
+ ///
+ UnsupportedCondition,
+}
+
+///
+/// The outcome of evaluating an access condition (or a combined rule result): an plus, when
+/// it is a denial, the . Build instances via , ,
+/// or , and fold a sequence together with .
+///
+public sealed record AccessEvaluation
+{
+ /// The evaluation's verdict.
+ public required AccessEvaluationOutcome Outcome { get; init; }
+
+ /// Why access was denied; unless is .
+ public DenyReason Reason { get; init; } = DenyReason.None;
+
+ /// A shared allow result.
+ public static AccessEvaluation Allow { get; } = new() { Outcome = AccessEvaluationOutcome.Allow };
+
+ /// A shared requires-approval result.
+ public static AccessEvaluation RequiresApproval { get; } = new() { Outcome = AccessEvaluationOutcome.RequiresApproval };
+
+ /// Builds a deny result carrying the given .
+ public static AccessEvaluation Deny(DenyReason reason) => new()
+ {
+ Outcome = AccessEvaluationOutcome.Deny,
+ Reason = reason
+ };
+
+ ///
+ /// Folds a sequence of per-condition evaluations into one, with deny > requires-approval > allow
+ /// precedence: the first deny short-circuits and is returned as-is; otherwise any requires-approval wins over
+ /// allow. An empty sequence is vacuously satisfied and returns .
+ ///
+ public static AccessEvaluation Combine(IEnumerable evaluations)
+ {
+ var requiresApproval = false;
+ foreach (var evaluation in evaluations)
+ {
+ switch (evaluation.Outcome)
+ {
+ case AccessEvaluationOutcome.Deny:
+ return evaluation;
+ case AccessEvaluationOutcome.RequiresApproval:
+ requiresApproval = true;
+ break;
+ }
+ }
+
+ return requiresApproval ? RequiresApproval : Allow;
+ }
+}
diff --git a/bitwarden_license/src/Services/Pam/Engine/AccessRuleEngine.cs b/bitwarden_license/src/Services/Pam/Engine/AccessRuleEngine.cs
new file mode 100644
index 000000000000..4d9fd19159fe
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Engine/AccessRuleEngine.cs
@@ -0,0 +1,19 @@
+using Bit.Services.Pam.Models.Conditions;
+
+namespace Bit.Services.Pam.Engine;
+
+///
+/// Combines the results of an access rule's flat list of s into one decision. Each
+/// condition evaluates itself (); the engine only folds those results, with
+/// deny taking precedence over a pending approval, which in turn takes precedence over allow. An empty list is
+/// vacuously satisfied (allow). Unparseable inputs fail closed before they reach the engine.
+///
+public sealed class AccessRuleEngine : IAccessRuleEngine
+{
+ public AccessEvaluation Evaluate(IReadOnlyList conditions, AccessSignals signals) =>
+ AccessEvaluation.Combine(conditions.Select(condition => EvaluateOne(condition, signals)));
+
+ private static AccessEvaluation EvaluateOne(AccessCondition? condition, AccessSignals signals) =>
+ // A null entry (malformed stored conditions) cannot be evaluated, so fail closed.
+ condition is null ? AccessEvaluation.Deny(DenyReason.UnsupportedCondition) : condition.Evaluate(signals);
+}
diff --git a/bitwarden_license/src/Services/Pam/Engine/AccessSignals.cs b/bitwarden_license/src/Services/Pam/Engine/AccessSignals.cs
new file mode 100644
index 000000000000..459cb4a9403a
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Engine/AccessSignals.cs
@@ -0,0 +1,25 @@
+using System.Net;
+
+namespace Bit.Services.Pam.Engine;
+
+///
+/// The request-time inputs an access rule is evaluated against: the caller's source IP and the instant the
+/// evaluation is performed. is null when the caller's address cannot be determined, which
+/// IP-restricted rules treat as a denial so access never opens up on a missing signal.
+///
+public sealed record AccessSignals
+{
+ public required IPAddress? IpAddress { get; init; }
+ public required DateTimeOffset Timestamp { get; init; }
+
+ ///
+ /// Builds the signals for the current request: the caller's source IP (parsed, or null when it is absent or
+ /// unparseable) and the supplied evaluation . Callers typically pass the request's
+ /// source address (e.g. ICurrentContext.IpAddress).
+ ///
+ public static AccessSignals From(string? ipAddress, DateTimeOffset timestamp) => new()
+ {
+ IpAddress = IPAddress.TryParse(ipAddress, out var ip) ? ip : null,
+ Timestamp = timestamp,
+ };
+}
diff --git a/bitwarden_license/src/Services/Pam/Engine/IAccessRuleEngine.cs b/bitwarden_license/src/Services/Pam/Engine/IAccessRuleEngine.cs
new file mode 100644
index 000000000000..b2de653b7f32
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Engine/IAccessRuleEngine.cs
@@ -0,0 +1,14 @@
+using Bit.Services.Pam.Models.Conditions;
+
+namespace Bit.Services.Pam.Engine;
+
+///
+/// Evaluates an access rule's conditions — a flat list of ANDed together — against
+/// the request-time , deciding whether access is allowed, denied, or gated on human
+/// approval. The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease
+/// commands and queries, which call the engine to decide whether a lease may be issued or its data handed over.
+///
+public interface IAccessRuleEngine
+{
+ AccessEvaluation Evaluate(IReadOnlyList conditions, AccessSignals signals);
+}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs
index 1cc88d98a4a3..31987ab3065d 100644
--- a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
+using Bit.Services.Pam.Engine;
namespace Bit.Services.Pam.Models.Conditions;
@@ -10,4 +11,18 @@ namespace Bit.Services.Pam.Models.Conditions;
[JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")]
[JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")]
[JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")]
-public abstract class AccessCondition;
+public abstract class AccessCondition
+{
+ ///
+ /// Evaluates this condition against the request-time , returning whether it allows,
+ /// denies, or requires human approval. The engine folds each condition's result into the rule's overall
+ /// decision; it does not know how any individual kind decides.
+ ///
+ public abstract AccessEvaluation Evaluate(AccessSignals signals);
+
+ ///
+ /// Checks this condition is well-formed at write time, before it is persisted, returning an actionable error
+ /// when it is not. The loud, reject-on-save counterpart to 's fail-closed runtime behavior.
+ ///
+ public abstract AccessRuleValidationResult Validate();
+}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessConditionJson.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessConditionJson.cs
new file mode 100644
index 000000000000..60e048d5ffc0
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessConditionJson.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+
+namespace Bit.Services.Pam.Models.Conditions;
+
+///
+/// The single source of truth for how an access rule's conditions JSON is (de)serialized: camelCase property
+/// names, read case-insensitively. Everything that parses the stored Conditions document — the validator
+/// at write time and the resolver at read time — must use so the two never drift.
+/// The accepted kind vocabulary itself lives on 's [JsonDerivedType]
+/// attributes.
+///
+public static class AccessConditionJson
+{
+ public static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessRuleValidationResult.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessRuleValidationResult.cs
new file mode 100644
index 000000000000..4a85f3f0abd8
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessRuleValidationResult.cs
@@ -0,0 +1,12 @@
+namespace Bit.Services.Pam.Models.Conditions;
+
+///
+/// The result of checking a condition (or a rule's whole conditions document) is well-formed: whether it is valid
+/// and, when not, an actionable message. Produced at write time by and by
+/// .
+///
+public sealed record AccessRuleValidationResult(bool IsValid, string? Error)
+{
+ public static AccessRuleValidationResult Valid { get; } = new(true, null);
+ public static AccessRuleValidationResult Invalid(string error) => new(false, error);
+}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs
index d7a7b4f7e288..cb88bd2672cd 100644
--- a/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs
@@ -1,6 +1,14 @@
-namespace Bit.Services.Pam.Models.Conditions;
+using Bit.Services.Pam.Engine;
+
+namespace Bit.Services.Pam.Models.Conditions;
///
/// Always requires a human decision before a lease can be issued.
///
-public sealed class HumanApprovalCondition : AccessCondition;
+/// Wire format: { "kind": "human_approval" }
+public sealed class HumanApprovalCondition : AccessCondition
+{
+ public override AccessEvaluation Evaluate(AccessSignals signals) => AccessEvaluation.RequiresApproval;
+
+ public override AccessRuleValidationResult Validate() => AccessRuleValidationResult.Valid;
+}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs
index fd63567945cc..5618ef93a689 100644
--- a/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs
@@ -1,9 +1,59 @@
-namespace Bit.Services.Pam.Models.Conditions;
+using System.Net;
+using Bit.Services.Pam.Engine;
+
+namespace Bit.Services.Pam.Models.Conditions;
///
/// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies.
///
+///
+/// Wire format:
+///
+/// { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8", "2001:db8::/32"] }
+///
+///
public sealed class IpAllowlistCondition : AccessCondition
{
+ ///
+ /// The allowed source ranges in CIDR notation (e.g. "10.0.0.0/8"). The condition allows when the caller's
+ /// IP is in any one of them. At least one required, and each must parse; an empty list denies.
+ ///
public IReadOnlyList Cidrs { get; init; } = [];
+
+ public override AccessEvaluation Evaluate(AccessSignals signals)
+ {
+ // An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed.
+ if (Cidrs.Count == 0 || signals.IpAddress is null)
+ {
+ return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
+ }
+
+ foreach (var cidr in Cidrs)
+ {
+ if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress))
+ {
+ return AccessEvaluation.Allow;
+ }
+ }
+
+ return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
+ }
+
+ public override AccessRuleValidationResult Validate()
+ {
+ if (Cidrs.Count == 0)
+ {
+ return AccessRuleValidationResult.Invalid("ip_allowlist requires at least one CIDR.");
+ }
+
+ foreach (var cidr in Cidrs)
+ {
+ if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _))
+ {
+ return AccessRuleValidationResult.Invalid($"Invalid CIDR: '{cidr}'.");
+ }
+ }
+
+ return AccessRuleValidationResult.Valid;
+ }
}
diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs
index 6b6a33bd1d0a..1e6aa17c8312 100644
--- a/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs
+++ b/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs
@@ -1,19 +1,133 @@
-using Bit.Services.Pam.Enums;
+using System.Globalization;
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Enums;
+
namespace Bit.Services.Pam.Models.Conditions;
///
/// Auto-approves a lease when the request falls inside one of the configured windows, evaluated in
/// the named IANA timezone; otherwise denies.
///
+///
+/// Wire format:
+///
+/// {
+/// "kind": "time_of_day",
+/// "tz": "America/New_York",
+/// "windows": [{ "days": ["mon", "tue", "wed", "thu", "fri"], "from": "09:00", "to": "17:00" }]
+/// }
+///
+///
public sealed class TimeOfDayCondition : AccessCondition
{
+ /// The IANA timezone (e.g. "America/New_York") the windows are evaluated in. An unknown or invalid zone denies.
public string Tz { get; init; } = string.Empty;
+
+ /// The windows that grant access; the condition allows when the instant falls in any one of them. At least one required.
public IReadOnlyList Windows { get; init; } = [];
+
+ public override AccessEvaluation Evaluate(AccessSignals signals)
+ {
+ if (!TimeZoneInfo.TryFindSystemTimeZoneById(Tz, out var timeZone))
+ {
+ // The window cannot be evaluated without a valid timezone, so fail closed.
+ return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
+ }
+
+ var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone);
+ var day = local.DayOfWeek;
+ var time = TimeOnly.FromTimeSpan(local.TimeOfDay);
+
+ return Windows.Any(window => window.Contains(day, time)) ? AccessEvaluation.Allow : AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
+ }
+
+ public override AccessRuleValidationResult Validate()
+ {
+ if (string.IsNullOrWhiteSpace(Tz))
+ {
+ return AccessRuleValidationResult.Invalid("time_of_day requires a tz.");
+ }
+
+ try
+ {
+ TimeZoneInfo.FindSystemTimeZoneById(Tz);
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ return AccessRuleValidationResult.Invalid($"Unknown timezone: '{Tz}'.");
+ }
+ catch (InvalidTimeZoneException)
+ {
+ return AccessRuleValidationResult.Invalid($"Invalid timezone: '{Tz}'.");
+ }
+
+ if (Windows.Count == 0)
+ {
+ return AccessRuleValidationResult.Invalid("time_of_day requires at least one window.");
+ }
+
+ return Windows.Select(window => window.Validate()).FirstOrDefault(result => !result.IsValid)
+ ?? AccessRuleValidationResult.Valid;
+ }
}
+///
+/// A single recurring window: the days it applies on and the daily start/end times, all in the parent
+/// condition's timezone.
+///
public sealed class TimeWindow
{
+ /// The days of the week the window is active on. At least one required.
public IReadOnlyList Days { get; init; } = [];
+
+ /// Window start, 24-hour HH:mm (00:00–23:59). Inclusive.
public string From { get; init; } = string.Empty;
+
+ /// Window end, 24-hour HH:mm (00:00–23:59). Inclusive.
public string To { get; init; } = string.Empty;
+
+ ///
+ /// Whether this window admits the given and (both already in the
+ /// parent condition's timezone). Unparseable bounds admit nothing, so the window fails closed.
+ ///
+ public bool Contains(DayOfWeek day, TimeOnly time)
+ {
+ // AccessWeekday values align with System.DayOfWeek (Sunday = 0), so a direct cast compares correctly.
+ if (Days.All(d => (DayOfWeek)d != day))
+ {
+ return false;
+ }
+
+ return TryParseTime(From, out var from)
+ && TryParseTime(To, out var to)
+ && time >= from && time <= to;
+ }
+
+ ///
+ /// Checks the window is well-formed: at least one day, and start/end times in 24-hour HH:mm. Uses the
+ /// same time parse as , so validation and evaluation cannot disagree on the format.
+ ///
+ public AccessRuleValidationResult Validate()
+ {
+ if (Days.Count == 0)
+ {
+ return AccessRuleValidationResult.Invalid("time_of_day window requires at least one day.");
+ }
+
+ // Day tokens were validated during deserialization by AccessWeekdayJsonConverter; only the times remain.
+ if (!TryParseTime(From, out _))
+ {
+ return AccessRuleValidationResult.Invalid($"Invalid 'from' time: '{From}'. Expected HH:mm.");
+ }
+
+ if (!TryParseTime(To, out _))
+ {
+ return AccessRuleValidationResult.Invalid($"Invalid 'to' time: '{To}'. Expected HH:mm.");
+ }
+
+ return AccessRuleValidationResult.Valid;
+ }
+
+ private static bool TryParseTime(string value, out TimeOnly time) =>
+ TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out time);
}
diff --git a/bitwarden_license/src/Services/Pam/Models/GoverningRule.cs b/bitwarden_license/src/Services/Pam/Models/GoverningRule.cs
new file mode 100644
index 000000000000..cb1b5a3ef780
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Models/GoverningRule.cs
@@ -0,0 +1,36 @@
+using Bit.Services.Pam.Models.Conditions;
+
+namespace Bit.Services.Pam.Models;
+
+///
+/// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning
+/// organization, whether the rule requires human approval, and the parsed flat list of s
+/// so the rule engine can evaluate them against the caller's signals. A null governing rule means the cipher is not
+/// leasing-gated for the caller.
+///
+public sealed record GoverningRule(
+ Guid OrganizationId,
+ Guid CollectionId,
+ bool RequiresHumanApproval,
+ IReadOnlyList Conditions)
+{
+ ///
+ /// The identity of the resolved access rule. Resolution is deterministic (oldest rule wins; see
+ /// ), so this is the rule that a request should pin at submit once
+ /// pinning is persisted. Until then it is re-resolved on every operation and can drift if the governing rules
+ /// change between submit and a later read.
+ ///
+ public Guid RuleId { get; init; }
+
+ ///
+ /// When true, a member holding an active lease under this rule may extend it once (always auto-approved), by up
+ /// to .
+ ///
+ public bool AllowsExtensions { get; init; }
+
+ ///
+ /// The longest a single extension under this rule may run, in seconds; meaningful only when
+ /// is true.
+ ///
+ public int? MaxExtensionDurationSeconds { get; init; }
+}
diff --git a/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs
index f4a48061991f..4ec2c86e4fbc 100644
--- a/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs
+++ b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs
@@ -1,23 +1,12 @@
-using System.Net;
-using System.Text.Json;
-using System.Text.RegularExpressions;
+using System.Text.Json;
using Bit.Services.Pam.Models.Conditions;
namespace Bit.Services.Pam.Services;
-public sealed partial class AccessRuleValidator : IAccessRuleValidator
+public sealed class AccessRuleValidator : IAccessRuleValidator
{
private const int MaxConditions = 10;
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true,
- };
-
- [GeneratedRegex(@"^([01][0-9]|2[0-3]):[0-5][0-9]$")]
- private static partial Regex TimeOfDayRegex();
-
public AccessRuleValidationResult Validate(string? conditionsJson)
{
if (conditionsJson is null)
@@ -33,7 +22,7 @@ public AccessRuleValidationResult Validate(string? conditionsJson)
List? conditions;
try
{
- conditions = JsonSerializer.Deserialize>(conditionsJson, JsonOptions);
+ conditions = JsonSerializer.Deserialize>(conditionsJson, AccessConditionJson.Options);
}
catch (JsonException ex)
{
@@ -53,85 +42,15 @@ public AccessRuleValidationResult Validate(string? conditionsJson)
return AccessRuleValidationResult.Invalid($"Conditions cannot contain more than {MaxConditions} conditions.");
}
+ // Each condition validates itself; the validator only enforces the document-level shape (it is an array,
+ // within the size limit) and guards the one thing a condition cannot check for itself: a null entry.
return conditions.Select(ValidateCondition).FirstOrDefault(result => !result.IsValid)
?? AccessRuleValidationResult.Valid;
}
- private static AccessRuleValidationResult ValidateCondition(AccessCondition? condition)
- {
- return condition switch
- {
- HumanApprovalCondition => AccessRuleValidationResult.Valid,
- IpAllowlistCondition ip => ValidateIpAllowlist(ip),
- TimeOfDayCondition tod => ValidateTimeOfDay(tod),
- null => AccessRuleValidationResult.Invalid("Conditions cannot contain a null entry."),
- _ => AccessRuleValidationResult.Invalid($"Unsupported condition kind: {condition.GetType().Name}."),
- };
- }
-
- private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistCondition condition)
- {
- if (condition.Cidrs.Count == 0)
- {
- return AccessRuleValidationResult.Invalid("ip_allowlist requires at least one CIDR.");
- }
-
- foreach (var cidr in condition.Cidrs)
- {
- if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _))
- {
- return AccessRuleValidationResult.Invalid($"Invalid CIDR: '{cidr}'.");
- }
- }
-
- return AccessRuleValidationResult.Valid;
- }
-
- private static AccessRuleValidationResult ValidateTimeOfDay(TimeOfDayCondition condition)
- {
- if (string.IsNullOrWhiteSpace(condition.Tz))
- {
- return AccessRuleValidationResult.Invalid("time_of_day requires a tz.");
- }
-
- try
- {
- TimeZoneInfo.FindSystemTimeZoneById(condition.Tz);
- }
- catch (TimeZoneNotFoundException)
- {
- return AccessRuleValidationResult.Invalid($"Unknown timezone: '{condition.Tz}'.");
- }
- catch (InvalidTimeZoneException)
- {
- return AccessRuleValidationResult.Invalid($"Invalid timezone: '{condition.Tz}'.");
- }
-
- if (condition.Windows.Count == 0)
- {
- return AccessRuleValidationResult.Invalid("time_of_day requires at least one window.");
- }
-
- foreach (var window in condition.Windows)
- {
- if (window.Days.Count == 0)
- {
- return AccessRuleValidationResult.Invalid("time_of_day window requires at least one day.");
- }
-
- // Day tokens are validated during deserialization by AccessWeekdayJsonConverter; an unknown token fails
- // the JSON parse above and is reported as malformed.
- if (!TimeOfDayRegex().IsMatch(window.From))
- {
- return AccessRuleValidationResult.Invalid($"Invalid 'from' time: '{window.From}'. Expected HH:mm.");
- }
-
- if (!TimeOfDayRegex().IsMatch(window.To))
- {
- return AccessRuleValidationResult.Invalid($"Invalid 'to' time: '{window.To}'. Expected HH:mm.");
- }
- }
-
- return AccessRuleValidationResult.Valid;
- }
+ private static AccessRuleValidationResult ValidateCondition(AccessCondition? condition) =>
+ // A JSON null in the array is not a condition and cannot validate itself, so it is rejected here.
+ condition is null
+ ? AccessRuleValidationResult.Invalid("Conditions cannot contain a null entry.")
+ : condition.Validate();
}
diff --git a/bitwarden_license/src/Services/Pam/Services/GoverningRuleResolver.cs b/bitwarden_license/src/Services/Pam/Services/GoverningRuleResolver.cs
new file mode 100644
index 000000000000..226956aef4e5
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Services/GoverningRuleResolver.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Pam.Entities;
+using Bit.Pam.Repositories;
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Models;
+using Bit.Services.Pam.Models.Conditions;
+
+namespace Bit.Services.Pam.Services;
+
+public class GoverningRuleResolver : IGoverningRuleResolver
+{
+ private readonly ICollectionCipherRepository _collectionCipherRepository;
+ private readonly ICollectionRepository _collectionRepository;
+ private readonly IAccessRuleRepository _accessRuleRepository;
+ private readonly IAccessRuleEngine _ruleEngine;
+
+ public GoverningRuleResolver(
+ ICollectionCipherRepository collectionCipherRepository,
+ ICollectionRepository collectionRepository,
+ IAccessRuleRepository accessRuleRepository,
+ IAccessRuleEngine ruleEngine)
+ {
+ _collectionCipherRepository = collectionCipherRepository;
+ _collectionRepository = collectionRepository;
+ _accessRuleRepository = accessRuleRepository;
+ _ruleEngine = ruleEngine;
+ }
+
+ public async Task ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals)
+ {
+ var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId);
+ if (collectionCiphers.Count == 0)
+ {
+ return null;
+ }
+
+ var collectionIds = collectionCiphers.Select(cc => cc.CollectionId).ToHashSet();
+ var collections = await _collectionRepository.GetManyByManyIdsAsync(collectionIds);
+
+ var governedCollections = collections
+ .Where(c => collectionIds.Contains(c.Id) && c.AccessRuleId.HasValue);
+
+ // Load every rule on the collections through which the caller reaches the cipher, keeping each paired with
+ // the collection it gates. A rule is dropped — so it stops governing — when it is disabled (Enabled is false;
+ // the admin has switched it off, and a disabled rule does not gate access) or no longer loads (deleted after
+ // the collection was read; deletes clear the link, so a missing rule is only a race). Dropping a disabled rule
+ // also stops it shadowing a newer active rule under the oldest-wins selection below.
+ var candidates = new List<(Collection Collection, AccessRule Rule)>();
+ foreach (var collection in governedCollections)
+ {
+ var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value);
+ if (accessRule is { Enabled: true })
+ {
+ candidates.Add((collection, accessRule));
+ }
+ }
+
+ if (candidates.Count == 0)
+ {
+ return null;
+ }
+
+ // Oldest wins: the rule with the earliest CreationDate governs, ties broken on rule id so the choice is total
+ // and stable. Selection is purely structural — it does NOT depend on how a rule's conditions evaluate for the
+ // current signals — so a newer path never pre-empts an older one, whichever is the more permissive. This is a
+ // deliberate trade of determinism over least-restriction: a member may be routed to an approver even though a
+ // newer path would have auto-granted, because the older rule governs. The chosen rule's conditions are then
+ // evaluated below only to decide whether it routes to a human or resolves automatically.
+ var (governingCollection, governingRule) = candidates
+ .OrderBy(c => c.Rule.CreationDate)
+ .ThenBy(c => c.Rule.Id)
+ .First();
+
+ var conditions = Parse(governingRule.Conditions);
+ var outcome = _ruleEngine.Evaluate(conditions, signals).Outcome;
+
+ return new GoverningRule(
+ governingCollection.OrganizationId,
+ governingCollection.Id,
+ outcome == AccessEvaluationOutcome.RequiresApproval,
+ conditions)
+ {
+ RuleId = governingRule.Id,
+ AllowsExtensions = governingRule.AllowsExtensions,
+ MaxExtensionDurationSeconds = governingRule.MaxExtensionDurationSeconds,
+ };
+ }
+
+ ///
+ /// Parses the stored conditions JSON into a flat list of . A malformed or
+ /// unparseable document fails safe to a single human-approval condition so access is never silently auto-approved
+ /// on conditions the server could not understand; the human-approval path then routes it to an approver rather
+ /// than issuing an automatic lease.
+ ///
+ private static IReadOnlyList Parse(string conditionsJson)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize>(conditionsJson, AccessConditionJson.Options) ?? FailSafe();
+ }
+ catch (JsonException)
+ {
+ return FailSafe();
+ }
+ }
+
+ private static IReadOnlyList FailSafe() => [new HumanApprovalCondition()];
+}
diff --git a/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs b/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs
index b706ccee6f8c..0e771c73bf35 100644
--- a/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs
+++ b/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs
@@ -1,4 +1,6 @@
-namespace Bit.Services.Pam.Services;
+using Bit.Services.Pam.Models.Conditions;
+
+namespace Bit.Services.Pam.Services;
public interface IAccessRuleValidator
{
@@ -8,9 +10,3 @@ public interface IAccessRuleValidator
///
AccessRuleValidationResult Validate(string? conditionsJson);
}
-
-public sealed record AccessRuleValidationResult(bool IsValid, string? Error)
-{
- public static AccessRuleValidationResult Valid { get; } = new(true, null);
- public static AccessRuleValidationResult Invalid(string error) => new(false, error);
-}
diff --git a/bitwarden_license/src/Services/Pam/Services/IGoverningRuleResolver.cs b/bitwarden_license/src/Services/Pam/Services/IGoverningRuleResolver.cs
new file mode 100644
index 000000000000..46fd3f820b31
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Services/IGoverningRuleResolver.cs
@@ -0,0 +1,19 @@
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Models;
+
+namespace Bit.Services.Pam.Services;
+
+public interface IGoverningRuleResolver
+{
+ ///
+ /// Resolves the access rule that governs for the caller, or null when the cipher is
+ /// not leasing-gated for them (no reachable collection carries an access rule). When more than one governing
+ /// collection applies, the oldest rule wins — the one with the earliest creation date, ties broken on rule id so
+ /// the choice is total and stable. Selection is purely structural and does NOT depend on how a rule's conditions
+ /// evaluate for the current : a newer path never pre-empts an older one, whichever is
+ /// the more permissive, so a caller may be routed to an approver even though a newer path would have auto-granted.
+ /// The resolved rule's conditions are still evaluated against to report whether it
+ /// requires human approval.
+ ///
+ Task ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals);
+}
diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs
index 8c055f68b44e..83d46595db9b 100644
--- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs
+++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Endpoints;
using Bit.Services.Pam.Api.Endpoints.Handlers;
+using Bit.Services.Pam.Engine;
using Bit.Services.Pam.OrganizationFeatures.Commands;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.Services;
@@ -17,6 +18,12 @@ public static IServiceCollection AddPamServices(this IServiceCollection services
services.AddScoped();
services.AddScoped();
+ // Rule evaluation engine. Pure and stateless, so a singleton is safe.
+ services.AddSingleton();
+
+ // Resolves the access rule governing a cipher for a caller, then evaluates it via the engine.
+ services.AddScoped();
+
// AccessRule write path.
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton();
diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs
index 6d15d453a279..1d3343d93f25 100644
--- a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs
+++ b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs
@@ -3,6 +3,7 @@
using Bit.Core.Repositories;
using Bit.Pam.Entities;
using Bit.Pam.Repositories;
+using Bit.Services.Pam.Models.Conditions;
using Bit.Services.Pam.OrganizationFeatures.Commands;
using Bit.Services.Pam.Services;
using Bit.Test.Common.AutoFixture;
diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs
index 656b5ef5c21e..06fc8779497b 100644
--- a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs
+++ b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs
@@ -4,6 +4,7 @@
using Bit.Pam.Entities;
using Bit.Pam.Models;
using Bit.Pam.Repositories;
+using Bit.Services.Pam.Models.Conditions;
using Bit.Services.Pam.OrganizationFeatures.Commands;
using Bit.Services.Pam.Services;
using Bit.Test.Common.AutoFixture;
diff --git a/bitwarden_license/test/Services/Pam.Test/Engine/AccessEvaluationTests.cs b/bitwarden_license/test/Services/Pam.Test/Engine/AccessEvaluationTests.cs
new file mode 100644
index 000000000000..9810cae99cd4
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Engine/AccessEvaluationTests.cs
@@ -0,0 +1,72 @@
+using Bit.Services.Pam.Engine;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Engine;
+
+public class AccessEvaluationTests
+{
+ [Fact]
+ public void Combine_Empty_Allows()
+ {
+ // An empty rule is vacuously satisfied.
+ Assert.Equal(AccessEvaluationOutcome.Allow, AccessEvaluation.Combine([]).Outcome);
+ }
+
+ [Fact]
+ public void Combine_AllAllow_Allows()
+ {
+ var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.Allow]);
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, result.Outcome);
+ }
+
+ [Fact]
+ public void Combine_ApprovalOverAllow_RequiresApproval()
+ {
+ var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.RequiresApproval]);
+
+ Assert.Equal(AccessEvaluationOutcome.RequiresApproval, result.Outcome);
+ }
+
+ [Fact]
+ public void Combine_DenyOutranksAllow_Denies()
+ {
+ var result = AccessEvaluation.Combine([AccessEvaluation.Allow, AccessEvaluation.Deny(DenyReason.NotWithinIpRange)]);
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, result.Reason);
+ }
+
+ [Fact]
+ public void Combine_DenyOutranksApproval_Denies()
+ {
+ // Deny beats a pending approval regardless of order: there is nothing to approve if access is barred.
+ var result = AccessEvaluation.Combine([AccessEvaluation.RequiresApproval, AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow)]);
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, result.Reason);
+ }
+
+ [Fact]
+ public void Combine_ApprovalAfterDeny_StillDenies()
+ {
+ var result = AccessEvaluation.Combine([AccessEvaluation.Deny(DenyReason.NotWithinIpRange), AccessEvaluation.RequiresApproval]);
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, result.Reason);
+ }
+
+ [Fact]
+ public void Combine_FirstDenyWins_PreservesItsReason()
+ {
+ // Combine short-circuits on the first deny, so its reason is the one reported.
+ var result = AccessEvaluation.Combine(
+ [
+ AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow),
+ AccessEvaluation.Deny(DenyReason.NotWithinIpRange),
+ ]);
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, result.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, result.Reason);
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Engine/AccessRuleEngineTests.cs b/bitwarden_license/test/Services/Pam.Test/Engine/AccessRuleEngineTests.cs
new file mode 100644
index 000000000000..b17c1ae09589
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Engine/AccessRuleEngineTests.cs
@@ -0,0 +1,86 @@
+using System.Net;
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Models.Conditions;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Engine;
+
+public class AccessRuleEngineTests
+{
+ private readonly AccessRuleEngine _sut = new();
+
+ private static AccessSignals Signals() => new()
+ {
+ IpAddress = IPAddress.Parse("10.1.2.3"),
+ Timestamp = new DateTimeOffset(2026, 6, 4, 12, 0, 0, TimeSpan.Zero),
+ };
+
+ [Fact]
+ public void Evaluate_NoConditions_Allows()
+ {
+ // A rule with no conditions is vacuously satisfied: access is auto-granted while still flowing through
+ // PAM for audit logging.
+ Assert.Equal(AccessEvaluationOutcome.Allow, _sut.Evaluate([], Signals()).Outcome);
+ }
+
+ [Fact]
+ public void Evaluate_DefersToEachConditionsOwnResult()
+ {
+ // The engine does not decide anything itself; it returns what the condition's Evaluate reports.
+ Assert.Equal(AccessEvaluationOutcome.RequiresApproval,
+ _sut.Evaluate([new StubCondition(AccessEvaluation.RequiresApproval)], Signals()).Outcome);
+ }
+
+ [Fact]
+ public void Evaluate_CombinesConditionResults_DenyWins()
+ {
+ // Folding is delegated to AccessEvaluation.Combine (deny > approval > allow); a single denying condition
+ // drives the whole rule to deny. The full precedence matrix is covered in AccessEvaluationTests.
+ var conditions = new AccessCondition[]
+ {
+ new StubCondition(AccessEvaluation.Allow),
+ new StubCondition(AccessEvaluation.Deny(DenyReason.NotWithinIpRange)),
+ };
+
+ var evaluation = _sut.Evaluate(conditions, Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_ForwardsSignalsToConditions()
+ {
+ var signals = Signals();
+ var condition = new StubCondition(AccessEvaluation.Allow);
+
+ _sut.Evaluate([condition], signals);
+
+ Assert.Same(signals, condition.ReceivedSignals);
+ }
+
+ [Fact]
+ public void Evaluate_NullConditionEntry_DeniesClosed()
+ {
+ // A null entry (only reachable from a malformed stored document) cannot be evaluated, so it fails closed.
+ // An unknown condition kind can no longer reach the engine: it is rejected at JSON deserialization.
+ var evaluation = _sut.Evaluate([null!], Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.UnsupportedCondition, evaluation.Reason);
+ }
+
+ /// A condition with a fixed result, isolating the engine's folding from any real condition logic.
+ private sealed class StubCondition(AccessEvaluation result) : AccessCondition
+ {
+ public AccessSignals? ReceivedSignals { get; private set; }
+
+ public override AccessEvaluation Evaluate(AccessSignals signals)
+ {
+ ReceivedSignals = signals;
+ return result;
+ }
+
+ public override AccessRuleValidationResult Validate() => AccessRuleValidationResult.Valid;
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs
new file mode 100644
index 000000000000..73698c554f08
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/AccessWeekdayJsonConverterTests.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using Bit.Services.Pam.Enums;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Models.Conditions;
+
+public class AccessWeekdayJsonConverterTests
+{
+ [Theory]
+ [InlineData(AccessWeekday.Sun, "\"sun\"")]
+ [InlineData(AccessWeekday.Mon, "\"mon\"")]
+ [InlineData(AccessWeekday.Tue, "\"tue\"")]
+ [InlineData(AccessWeekday.Wed, "\"wed\"")]
+ [InlineData(AccessWeekday.Thu, "\"thu\"")]
+ [InlineData(AccessWeekday.Fri, "\"fri\"")]
+ [InlineData(AccessWeekday.Sat, "\"sat\"")]
+ public void Serializes_AsLowercaseToken(AccessWeekday day, string expectedJson)
+ {
+ Assert.Equal(expectedJson, JsonSerializer.Serialize(day));
+ }
+
+ [Theory]
+ [InlineData("\"mon\"", AccessWeekday.Mon)]
+ [InlineData("\"MON\"", AccessWeekday.Mon)]
+ [InlineData("\"Sun\"", AccessWeekday.Sun)]
+ public void Deserializes_TokenCaseInsensitively(string json, AccessWeekday expected)
+ {
+ Assert.Equal(expected, JsonSerializer.Deserialize(json));
+ }
+
+ [Theory]
+ [InlineData("\"funday\"")]
+ [InlineData("\"\"")]
+ [InlineData("3")]
+ public void Deserialize_InvalidToken_Throws(string json)
+ {
+ Assert.Throws(() => JsonSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void EnumValues_AlignWithSystemDayOfWeek()
+ {
+ // The engine casts AccessWeekday straight to System.DayOfWeek, so the numeric values must match.
+ Assert.Equal((int)DayOfWeek.Sunday, (int)AccessWeekday.Sun);
+ Assert.Equal((int)DayOfWeek.Monday, (int)AccessWeekday.Mon);
+ Assert.Equal((int)DayOfWeek.Tuesday, (int)AccessWeekday.Tue);
+ Assert.Equal((int)DayOfWeek.Wednesday, (int)AccessWeekday.Wed);
+ Assert.Equal((int)DayOfWeek.Thursday, (int)AccessWeekday.Thu);
+ Assert.Equal((int)DayOfWeek.Friday, (int)AccessWeekday.Fri);
+ Assert.Equal((int)DayOfWeek.Saturday, (int)AccessWeekday.Sat);
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Models/Conditions/IpAllowlistConditionTests.cs b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/IpAllowlistConditionTests.cs
new file mode 100644
index 000000000000..840392386f94
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/IpAllowlistConditionTests.cs
@@ -0,0 +1,109 @@
+using System.Net;
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Models.Conditions;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Models.Conditions;
+
+public class IpAllowlistConditionTests
+{
+ private static AccessSignals Signals(IPAddress? ip) => new()
+ {
+ IpAddress = ip,
+ Timestamp = new DateTimeOffset(2026, 6, 4, 12, 0, 0, TimeSpan.Zero),
+ };
+
+ [Fact]
+ public void Evaluate_IpInRange_Allows()
+ {
+ var condition = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] };
+
+ var evaluation = condition.Evaluate(Signals(IPAddress.Parse("10.1.2.3")));
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
+ }
+
+ [Fact]
+ public void Evaluate_IpOutOfRange_Denies()
+ {
+ var condition = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] };
+
+ var evaluation = condition.Evaluate(Signals(IPAddress.Parse("192.168.1.1")));
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_UnknownIp_DeniesClosed()
+ {
+ var condition = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8"] };
+
+ var evaluation = condition.Evaluate(Signals(ip: null));
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_NoEntries_DeniesClosed()
+ {
+ // An allowlist with no entries permits no address.
+ var evaluation = new IpAllowlistCondition().Evaluate(Signals(IPAddress.Parse("10.1.2.3")));
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_MalformedCidr_DeniesClosed()
+ {
+ // A present-but-unparseable CIDR matches no address, so a caller with a known IP still fails closed.
+ var condition = new IpAllowlistCondition { Cidrs = ["not-a-cidr"] };
+
+ var evaluation = condition.Evaluate(Signals(IPAddress.Parse("10.1.2.3")));
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinIpRange, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_LaterCidrMatches_Allows()
+ {
+ // The caller matches the second entry, so evaluation must not stop at the first non-matching CIDR.
+ var condition = new IpAllowlistCondition { Cidrs = ["192.168.0.0/16", "10.0.0.0/8"] };
+
+ var evaluation = condition.Evaluate(Signals(IPAddress.Parse("10.1.2.3")));
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
+ }
+
+ [Fact]
+ public void Validate_NoCidrs_IsInvalid()
+ {
+ var result = new IpAllowlistCondition().Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("at least one CIDR", result.Error);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("not-a-cidr")]
+ [InlineData("10.0.0.0/99")]
+ public void Validate_InvalidCidr_IsInvalid(string cidr)
+ {
+ var result = new IpAllowlistCondition { Cidrs = [cidr] }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("Invalid CIDR", result.Error);
+ }
+
+ [Fact]
+ public void Validate_ValidCidrs_IsValid()
+ {
+ var result = new IpAllowlistCondition { Cidrs = ["10.0.0.0/8", "2001:db8::/32"] }.Validate();
+
+ Assert.True(result.IsValid);
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Models/Conditions/TimeOfDayConditionTests.cs b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/TimeOfDayConditionTests.cs
new file mode 100644
index 000000000000..e4cfd4aec213
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Models/Conditions/TimeOfDayConditionTests.cs
@@ -0,0 +1,254 @@
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Enums;
+using Bit.Services.Pam.Models.Conditions;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Models.Conditions;
+
+public class TimeOfDayConditionTests
+{
+ // 2026-06-04T12:00:00Z is a Thursday, so "thu" windows match in UTC.
+ private static readonly DateTimeOffset _now = new(2026, 6, 4, 12, 0, 0, TimeSpan.Zero);
+
+ private static AccessSignals Signals(DateTimeOffset? at = null) => new()
+ {
+ IpAddress = null,
+ Timestamp = at ?? _now,
+ };
+
+ [Fact]
+ public void Evaluate_WithinWindow_Allows()
+ {
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "UTC",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" }],
+ };
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, condition.Evaluate(Signals()).Outcome);
+ }
+
+ [Fact]
+ public void Evaluate_OutsideWindow_Denies()
+ {
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "UTC",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "06:00" }],
+ };
+
+ var evaluation = condition.Evaluate(Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_DayNotListed_Denies()
+ {
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "UTC",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Fri], From = "00:00", To = "23:59" }],
+ };
+
+ var evaluation = condition.Evaluate(Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_EvaluatesInConfiguredTimezone_Allows()
+ {
+ // 23:00 UTC is 19:00 (Thursday) in America/New_York during June DST, inside the window.
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "America/New_York",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "18:00", To = "20:00" }],
+ };
+
+ var evaluation = condition.Evaluate(Signals(new DateTimeOffset(2026, 6, 4, 23, 0, 0, TimeSpan.Zero)));
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, evaluation.Outcome);
+ }
+
+ [Fact]
+ public void Evaluate_UnknownTimezone_DeniesClosed()
+ {
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "Not/AZone",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "00:00", To = "23:59" }],
+ };
+
+ var evaluation = condition.Evaluate(Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_MalformedWindowTime_DeniesClosed()
+ {
+ // The day matches but the window's bounds are unparseable, so the window cannot admit the caller.
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "UTC",
+ Windows = [new TimeWindow { Days = [AccessWeekday.Thu], From = "25:99", To = "30:00" }],
+ };
+
+ var evaluation = condition.Evaluate(Signals());
+
+ Assert.Equal(AccessEvaluationOutcome.Deny, evaluation.Outcome);
+ Assert.Equal(DenyReason.NotWithinTimeWindow, evaluation.Reason);
+ }
+
+ [Fact]
+ public void Evaluate_LaterWindowMatches_Allows()
+ {
+ // The first window is the wrong day; the second admits the caller, so windows past the first must be checked.
+ var condition = new TimeOfDayCondition
+ {
+ Tz = "UTC",
+ Windows =
+ [
+ new TimeWindow { Days = [AccessWeekday.Fri], From = "09:00", To = "17:00" },
+ new TimeWindow { Days = [AccessWeekday.Thu], From = "09:00", To = "17:00" },
+ ],
+ };
+
+ Assert.Equal(AccessEvaluationOutcome.Allow, condition.Evaluate(Signals()).Outcome);
+ }
+
+ [Fact]
+ public void Validate_MissingTz_IsInvalid()
+ {
+ var result = new TimeOfDayCondition { Windows = [ValidWindow()] }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("tz", result.Error);
+ }
+
+ [Fact]
+ public void Validate_UnknownTz_IsInvalid()
+ {
+ var result = new TimeOfDayCondition { Tz = "Invalid/Zone", Windows = [ValidWindow()] }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("timezone", result.Error);
+ }
+
+ [Fact]
+ public void Validate_NoWindows_IsInvalid()
+ {
+ var result = new TimeOfDayCondition { Tz = "UTC", Windows = [] }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("at least one window", result.Error);
+ }
+
+ [Fact]
+ public void Validate_Valid_IsValid()
+ {
+ var result = new TimeOfDayCondition { Tz = "UTC", Windows = [ValidWindow()] }.Validate();
+
+ Assert.True(result.IsValid);
+ }
+
+ private static TimeWindow ValidWindow() => new() { Days = [AccessWeekday.Mon], From = "09:00", To = "17:00" };
+}
+
+public class TimeWindowTests
+{
+ private static TimeWindow BusinessHours() => new()
+ {
+ Days = [AccessWeekday.Thu],
+ From = "09:00",
+ To = "17:00",
+ };
+
+ [Fact]
+ public void Contains_TimeEqualsFrom_IsInclusive()
+ {
+ // The lower bound is inclusive.
+ Assert.True(BusinessHours().Contains(DayOfWeek.Thursday, new TimeOnly(9, 0)));
+ }
+
+ [Fact]
+ public void Contains_TimeEqualsTo_IsInclusive()
+ {
+ // The upper bound is inclusive.
+ Assert.True(BusinessHours().Contains(DayOfWeek.Thursday, new TimeOnly(17, 0)));
+ }
+
+ [Fact]
+ public void Contains_TimeInside_True()
+ {
+ Assert.True(BusinessHours().Contains(DayOfWeek.Thursday, new TimeOnly(12, 0)));
+ }
+
+ [Fact]
+ public void Contains_TimeJustBeforeFrom_False()
+ {
+ Assert.False(BusinessHours().Contains(DayOfWeek.Thursday, new TimeOnly(8, 59)));
+ }
+
+ [Fact]
+ public void Contains_TimeJustAfterTo_False()
+ {
+ Assert.False(BusinessHours().Contains(DayOfWeek.Thursday, new TimeOnly(17, 1)));
+ }
+
+ [Fact]
+ public void Contains_DayNotListed_False()
+ {
+ Assert.False(BusinessHours().Contains(DayOfWeek.Friday, new TimeOnly(12, 0)));
+ }
+
+ [Fact]
+ public void Contains_UnparseableBounds_FailsClosed()
+ {
+ // Bounds that are not HH:mm admit nothing, even on a matching day and a plausible time.
+ var window = new TimeWindow { Days = [AccessWeekday.Thu], From = "9am", To = "5pm" };
+
+ Assert.False(window.Contains(DayOfWeek.Thursday, new TimeOnly(12, 0)));
+ }
+
+ [Fact]
+ public void Validate_NoDays_IsInvalid()
+ {
+ var result = new TimeWindow { Days = [], From = "09:00", To = "17:00" }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("at least one day", result.Error);
+ }
+
+ [Theory]
+ [InlineData("9am", "17:00")]
+ [InlineData("25:00", "17:00")]
+ public void Validate_InvalidFrom_IsInvalid(string from, string to)
+ {
+ var result = new TimeWindow { Days = [AccessWeekday.Mon], From = from, To = to }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("Expected HH:mm", result.Error);
+ }
+
+ [Fact]
+ public void Validate_InvalidTo_IsInvalid()
+ {
+ var result = new TimeWindow { Days = [AccessWeekday.Mon], From = "09:00", To = "5pm" }.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Contains("Expected HH:mm", result.Error);
+ }
+
+ [Fact]
+ public void Validate_ValidWindow_IsValid()
+ {
+ var result = new TimeWindow { Days = [AccessWeekday.Mon], From = "09:00", To = "17:00" }.Validate();
+
+ Assert.True(result.IsValid);
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs
index aaededee765c..686d6245e88f 100644
--- a/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs
+++ b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs
@@ -62,66 +62,14 @@ public void Validate_LegacyAllOfKind_IsInvalid()
}
[Fact]
- public void Validate_HumanApproval_IsValid()
+ public void Validate_TimeOfDay_UnknownWeekdayToken_IsInvalid()
{
- var result = _sut.Validate("""[{"kind":"human_approval"}]""");
-
- Assert.True(result.IsValid);
- }
-
- [Theory]
- [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""")]
- [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.0.0/16","2001:db8::/32"]}]""")]
- public void Validate_IpAllowlist_ValidCidrs_IsValid(string conditionsJson)
- {
- var result = _sut.Validate(conditionsJson);
-
- Assert.True(result.IsValid);
- }
-
- [Theory]
- [InlineData("""[{"kind":"ip_allowlist","cidrs":[]}]""", "at least one CIDR")]
- [InlineData("""[{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}]""", "Invalid CIDR")]
- [InlineData("""[{"kind":"ip_allowlist","cidrs":["10.0.0.0/99"]}]""", "Invalid CIDR")]
- public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string conditionsJson, string expectedMessageFragment)
- {
- var result = _sut.Validate(conditionsJson);
-
- Assert.False(result.IsValid);
- Assert.Contains(expectedMessageFragment, result.Error);
- }
-
- [Fact]
- public void Validate_TimeOfDay_Valid_IsValid()
- {
- var result = _sut.Validate("""
- [
- {
- "kind": "time_of_day",
- "tz": "UTC",
- "windows": [
- { "days": ["mon","tue","wed","thu","fri"], "from": "09:00", "to": "18:00" }
- ]
- }
- ]
- """);
-
- Assert.True(result.IsValid);
- }
-
- [Theory]
- [InlineData("""[{"kind":"time_of_day","tz":"Invalid/Zone","windows":[{"days":["mon"],"from":"09:00","to":"17:00"}]}]""", "timezone")]
- [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[]}]""", "at least one window")]
- [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":[],"from":"09:00","to":"17:00"}]}]""", "at least one day")]
- [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}]""", "day")]
- [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"9am","to":"5pm"}]}]""", "Expected HH:mm")]
- [InlineData("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["mon"],"from":"25:00","to":"26:00"}]}]""", "Expected HH:mm")]
- public void Validate_TimeOfDay_Invalid_IsInvalid(string conditionsJson, string expectedMessageFragment)
- {
- var result = _sut.Validate(conditionsJson);
+ // An unknown weekday token fails deserialization (via AccessWeekdayJsonConverter) before any condition-level
+ // check runs, so the whole document is rejected as malformed. Per-condition rules are covered by the
+ // condition tests (IpAllowlistConditionTests, TimeOfDayConditionTests).
+ var result = _sut.Validate("""[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["funday"],"from":"09:00","to":"17:00"}]}]""");
Assert.False(result.IsValid);
- Assert.Contains(expectedMessageFragment, result.Error);
}
[Fact]
diff --git a/bitwarden_license/test/Services/Pam.Test/Services/GoverningRuleResolverTests.cs b/bitwarden_license/test/Services/Pam.Test/Services/GoverningRuleResolverTests.cs
new file mode 100644
index 000000000000..1b7eb336730f
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Services/GoverningRuleResolverTests.cs
@@ -0,0 +1,377 @@
+using System.Net;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Pam.Entities;
+using Bit.Pam.Repositories;
+using Bit.Services.Pam.Engine;
+using Bit.Services.Pam.Enums;
+using Bit.Services.Pam.Models.Conditions;
+using Bit.Services.Pam.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Services;
+
+[SutProviderCustomize]
+public class GoverningRuleResolverTests
+{
+ // Selection is structural (oldest rule wins); the resolver still evaluates the chosen rule's conditions to report
+ // whether it needs human approval, so the tests drive the real engine through the substitute for that step.
+ private static readonly IAccessRuleEngine _engine = new AccessRuleEngine();
+
+ // An in-range IP for the 10.0.0.0/8 allowlists below; out-of-range for the 192.168/172.16 allowlists, which
+ // therefore deny. No time-of-day conditions are used, so the timestamp is arbitrary.
+ private static readonly AccessSignals _signals = new()
+ {
+ IpAddress = IPAddress.Parse("10.0.0.5"),
+ Timestamp = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero),
+ };
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_NoReachableCollections_ReturnsNull(
+ SutProvider sutProvider, Guid userId, Guid cipherId)
+ {
+ sutProvider.GetDependency()
+ .GetManyByUserIdCipherIdAsync(userId, cipherId)
+ .Returns(new List());
+
+ Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_CollectionWithoutAccessRule_ReturnsNull(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection)
+ {
+ collection.AccessRuleId = null;
+ SetupReachableCollections(sutProvider, userId, cipherId, collection);
+
+ Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_HumanApprovalCondition_RequiresHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ rule.Conditions = """[{"kind":"human_approval"}]""";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.True(result!.RequiresHumanApproval);
+ Assert.Equal(collection.Id, result.CollectionId);
+ Assert.Equal(collection.OrganizationId, result.OrganizationId);
+ Assert.IsType(Assert.Single(result.Conditions));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_PassingIpAllowlistCondition_DoesNotRequireHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.False(result!.RequiresHumanApproval);
+ var ip = Assert.IsType(Assert.Single(result.Conditions));
+ Assert.Equal("10.0.0.0/8", Assert.Single(ip.Cidrs));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_ConditionsContainingHumanApproval_RequiresHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ rule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]},{"kind":"human_approval"}]""";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.True(result!.RequiresHumanApproval);
+ Assert.Equal(2, result.Conditions.Count);
+ Assert.Contains(result.Conditions, condition => condition is HumanApprovalCondition);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_EmptyConditions_DoesNotRequireHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ // A conditionless rule governs the collection for audit logging but auto-approves access.
+ rule.Conditions = "[]";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.False(result!.RequiresHumanApproval);
+ Assert.Empty(result.Conditions);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_MalformedRule_FailsSafeToHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ rule.Conditions = "not json";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.True(result!.RequiresHumanApproval);
+ // An unparseable rule fails safe to human approval rather than surfacing a rule the engine cannot evaluate.
+ Assert.IsType(Assert.Single(result.Conditions));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_MultipleRules_OldestCreationDateWins(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The older rule needs human approval; the newer one would auto-grant. Oldest wins even though it is the more
+ // restrictive path — the caller is routed to an approver rather than auto-granted (do not reintroduce the
+ // retired least-restrictive behaviour).
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ olderRule.Conditions = """[{"kind":"human_approval"}]""";
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (olderCollection, olderRule), (newerCollection, newerRule));
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.True(result!.RequiresHumanApproval);
+ Assert.Equal(olderCollection.Id, result.CollectionId);
+ Assert.Equal(olderRule.Id, result.RuleId);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_MultipleRules_OlderAutomaticWinsOverNewerHumanApproval(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The mirror of the previous case: here the oldest rule auto-grants and the newer one needs human approval, so
+ // the caller is auto-granted. Whichever is older governs, regardless of which is more permissive.
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ olderRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"human_approval"}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (olderCollection, olderRule), (newerCollection, newerRule));
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.False(result!.RequiresHumanApproval);
+ Assert.Equal(olderCollection.Id, result.CollectionId);
+ Assert.Equal(olderRule.Id, result.RuleId);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_OldestRuleFailsAutomatedConditions_StillGoverns(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The oldest rule's IP allowlist fails for this caller; a newer rule would pass. Selection is structural, so
+ // the failing oldest rule still governs — the resolver never lets a newer path pre-empt it by evaluating
+ // conditions. (Downstream, the auto path then surfaces the denial; that is not the resolver's concern.)
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ olderRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["192.168.0.0/16"]}]""";
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (olderCollection, olderRule), (newerCollection, newerRule));
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.False(result!.RequiresHumanApproval);
+ Assert.Equal(olderCollection.Id, result.CollectionId);
+ Assert.Equal(olderRule.Id, result.RuleId);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_TieOnCreationDate_LowerRuleIdWins(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection lowerCollection, AccessRule lowerRule, Collection higherCollection, AccessRule higherRule)
+ {
+ // Two rules created at the same instant: the tie breaks on rule id (lowest wins) so the choice is total and
+ // stable rather than dependent on iteration order.
+ var sharedCreation = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
+ lowerRule.Id = new Guid("00000000-0000-0000-0000-000000000001");
+ lowerRule.CreationDate = sharedCreation;
+ lowerRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ higherRule.Id = new Guid("00000000-0000-0000-0000-000000000002");
+ higherRule.CreationDate = sharedCreation;
+ higherRule.Conditions = """[{"kind":"human_approval"}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (higherCollection, higherRule), (lowerCollection, lowerRule));
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.Equal(lowerRule.Id, result!.RuleId);
+ Assert.Equal(lowerCollection.Id, result.CollectionId);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_OldestRuleMalformed_FailsSafeToHumanApprovalEvenWithNewerAutoPath(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The oldest rule is unparseable; a newer rule would auto-grant. Because the oldest rule governs, it fails safe
+ // to human approval rather than letting the newer parseable path auto-grant around it.
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ olderRule.Conditions = "not json";
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (olderCollection, olderRule), (newerCollection, newerRule));
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.True(result!.RequiresHumanApproval);
+ Assert.Equal(olderCollection.Id, result.CollectionId);
+ Assert.IsType(Assert.Single(result.Conditions));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_GovernedRuleDeleted_ReturnsNull(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ // The collection still points at a rule id, but the rule no longer loads (deleted after the collection was
+ // read). It is dropped from the candidates, leaving nothing to govern — GetByIdAsync is left unstubbed so it
+ // returns null.
+ collection.AccessRuleId = rule.Id;
+ SetupReachableCollections(sutProvider, userId, cipherId, collection);
+
+ Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_OldestGovernedRuleDeleted_NextRuleGoverns(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The oldest governing rule was deleted after the collection was read, so it is skipped and the surviving
+ // newer rule governs — a deleted rule stops governing even when it would otherwise have won on age.
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"human_approval"}]""";
+ newerRule.Enabled = true;
+ olderCollection.AccessRuleId = olderRule.Id;
+ newerCollection.AccessRuleId = newerRule.Id;
+ SetupReachableCollections(sutProvider, userId, cipherId, olderCollection, newerCollection);
+ // Only the newer rule loads; GetByIdAsync(olderRule.Id) is left unstubbed so the deleted oldest returns null.
+ sutProvider.GetDependency().GetByIdAsync(newerRule.Id).Returns(newerRule);
+ DriveRealEngine(sutProvider);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.Equal(newerCollection.Id, result!.CollectionId);
+ Assert.Equal(newerRule.Id, result.RuleId);
+ Assert.True(result.RequiresHumanApproval);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_DisabledRule_NotGoverned(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ // A disabled rule is inactive and does not gate access, so a cipher reached only through it is ungoverned.
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+ rule.Enabled = false;
+
+ Assert.Null(await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals));
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_OldestRuleDisabled_NewerEnabledRuleGoverns(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ Collection olderCollection, AccessRule olderRule, Collection newerCollection, AccessRule newerRule)
+ {
+ // The oldest rule is disabled and auto-granting; the newer rule is enabled and needs human approval. A disabled
+ // rule must not shadow a newer active one, so the newer rule governs — access is not silently auto-granted.
+ olderRule.CreationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ olderRule.Conditions = "[]";
+ newerRule.CreationDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ newerRule.Conditions = """[{"kind":"human_approval"}]""";
+ SetupGovernedCollections(sutProvider, userId, cipherId,
+ (olderCollection, olderRule), (newerCollection, newerRule));
+ olderRule.Enabled = false;
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.Equal(newerCollection.Id, result!.CollectionId);
+ Assert.Equal(newerRule.Id, result.RuleId);
+ Assert.True(result.RequiresHumanApproval);
+ }
+
+ [Theory, BitAutoData]
+ public async Task ResolveAsync_TimeOfDayCondition_ParsedAndEvaluatedThroughResolver(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ {
+ // _signals' timestamp (2026-01-01 12:00 UTC) is a Thursday inside this window, so the rule auto-approves. This
+ // exercises the resolver's own camelCase parse of a time_of_day condition, including the weekday-token converter.
+ rule.Conditions = """[{"kind":"time_of_day","tz":"UTC","windows":[{"days":["thu"],"from":"09:00","to":"17:00"}]}]""";
+ SetupGovernedCollection(sutProvider, userId, cipherId, collection, rule);
+
+ var result = await sutProvider.Sut.ResolveAsync(userId, cipherId, _signals);
+
+ Assert.NotNull(result);
+ Assert.False(result!.RequiresHumanApproval);
+ var time = Assert.IsType(Assert.Single(result.Conditions));
+ Assert.Equal("UTC", time.Tz);
+ Assert.Equal(AccessWeekday.Thu, Assert.Single(Assert.Single(time.Windows).Days));
+ }
+
+ private static void SetupReachableCollections(
+ SutProvider sutProvider, Guid userId, Guid cipherId, params Collection[] collections)
+ {
+ sutProvider.GetDependency()
+ .GetManyByUserIdCipherIdAsync(userId, cipherId)
+ .Returns(collections.Select(c => new CollectionCipher { CollectionId = c.Id, CipherId = cipherId }).ToList());
+ sutProvider.GetDependency()
+ .GetManyByManyIdsAsync(Arg.Any>())
+ .Returns(collections.ToList());
+ }
+
+ private static void SetupGovernedCollection(
+ SutProvider sutProvider, Guid userId, Guid cipherId, Collection collection, AccessRule rule)
+ => SetupGovernedCollections(sutProvider, userId, cipherId, (collection, rule));
+
+ private static void SetupGovernedCollections(
+ SutProvider sutProvider, Guid userId, Guid cipherId,
+ params (Collection collection, AccessRule rule)[] pairs)
+ {
+ foreach (var (collection, rule) in pairs)
+ {
+ collection.AccessRuleId = rule.Id;
+ // A governing rule must be enabled; pin it so the outcome does not depend on AutoFixture's bool sequence.
+ rule.Enabled = true;
+ }
+
+ SetupReachableCollections(sutProvider, userId, cipherId, pairs.Select(p => p.collection).ToArray());
+
+ foreach (var (_, rule) in pairs)
+ {
+ sutProvider.GetDependency().GetByIdAsync(rule.Id).Returns(rule);
+ }
+
+ DriveRealEngine(sutProvider);
+ }
+
+ private static void DriveRealEngine(SutProvider sutProvider)
+ {
+ // Drive the real engine through the substitute so resolution exercises true IP/time evaluation.
+ sutProvider.GetDependency()
+ .Evaluate(Arg.Any>(), Arg.Any())
+ .Returns(ci => _engine.Evaluate(ci.ArgAt>(0), ci.ArgAt(1)));
+ }
+}