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:0023:59). Inclusive. public string From { get; init; } = string.Empty; + + /// Window end, 24-hour HH:mm (00:0023: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))); + } +}