diff --git a/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs index 2a7a558ce15a..8fe214f52ec3 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs @@ -1,6 +1,10 @@ -using Bit.HttpExtensions; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.HttpExtensions; +using Bit.Pam.Repositories; using Bit.Services.Pam.Api.Models.Request; using Bit.Services.Pam.Api.Models.Response; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; namespace Bit.Services.Pam.Api.Endpoints.Handlers; @@ -8,25 +12,75 @@ namespace Bit.Services.Pam.Api.Endpoints.Handlers; /// Handler for the organizations/{orgId}/access-rules resource. The Minimal API endpoints (see /// AccessRuleEndpoints) resolve this handler from DI. /// -/// -/// Scaffold only: the method signatures define the wire contract (request/response models, status codes) that the -/// generated OpenAPI spec and client bindings are built from. The bodies are intentionally unimplemented — the -/// behavior lands with the rest of the PAM feature. -/// -public class AccessRuleEndpointsHandler +public class AccessRuleEndpointsHandler( + ICurrentContext currentContext, + IAccessRuleRepository repository, + ICreateAccessRuleCommand createCommand, + IUpdateAccessRuleCommand updateCommand, + IDeleteAccessRuleCommand deleteCommand) { - public Task> GetAll(Guid orgId) - => throw new NotImplementedException(); + public async Task> GetAll(Guid orgId) + { + await EnsureMemberAsync(orgId); - public Task Get(Guid orgId, Guid id) - => throw new NotImplementedException(); + var rules = await repository.GetManyDetailsByOrganizationIdAsync(orgId); + return new ListResponseModel( + rules.Select(rule => new AccessRuleResponseModel(rule))); + } - public Task Post(Guid orgId, AccessRuleRequestModel model) - => throw new NotImplementedException(); + public async Task Get(Guid orgId, Guid id) + { + await EnsureMemberAsync(orgId); - public Task Put(Guid orgId, Guid id, AccessRuleRequestModel model) - => throw new NotImplementedException(); + var rule = await repository.GetDetailsByIdAsync(id); + if (rule is null || rule.OrganizationId != orgId) + { + throw new NotFoundException(); + } - public Task Delete(Guid orgId, Guid id) - => throw new NotImplementedException(); + return new AccessRuleResponseModel(rule); + } + + public async Task Post(Guid orgId, AccessRuleRequestModel model) + { + await EnsureAdminAsync(orgId); + + var toCreate = model.ToAccessRule(orgId); + toCreate.LastEditedBy = currentContext.UserId; + var rule = await createCommand.CreateAsync(toCreate, model.Collections); + return new AccessRuleResponseModel(rule); + } + + public async Task Put(Guid orgId, Guid id, AccessRuleRequestModel model) + { + await EnsureAdminAsync(orgId); + + var toUpdate = model.ToAccessRule(orgId); + toUpdate.LastEditedBy = currentContext.UserId; + var rule = await updateCommand.UpdateAsync(orgId, id, toUpdate, model.Collections); + return new AccessRuleResponseModel(rule); + } + + public async Task Delete(Guid orgId, Guid id) + { + await EnsureAdminAsync(orgId); + + await deleteCommand.DeleteAsync(orgId, id, currentContext.UserId); + } + + private async Task EnsureMemberAsync(Guid orgId) + { + if (!await currentContext.OrganizationUser(orgId)) + { + throw new NotFoundException(); + } + } + + private async Task EnsureAdminAsync(Guid orgId) + { + if (!await currentContext.OrganizationAdmin(orgId) && !await currentContext.OrganizationOwner(orgId)) + { + throw new NotFoundException(); + } + } } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs index 40e8458d0236..101ed8729712 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Pam.Entities; namespace Bit.Services.Pam.Api.Models.Request; @@ -23,9 +25,10 @@ public class AccessRuleRequestModel public bool Enabled { get; set; } = true; /// - /// The condition tree that decides how access is granted under this rule — for example requiring human - /// approval, or restricting to certain times of day or source IPs. Sent as a JSON object and stored verbatim; - /// an empty or null value means the rule imposes no conditions. + /// The conditions that decide how access is granted under this rule — for example requiring human + /// approval, or restricting to certain times of day or source IPs. Sent as a JSON array of condition + /// objects and stored verbatim. Required — a null or omitted value is rejected; an empty array means + /// the rule imposes no conditions, so requests under it resolve automatically. /// [Required] public object Conditions { get; set; } = null!; @@ -65,4 +68,24 @@ public class AccessRuleRequestModel /// [Required] public IEnumerable Collections { get; set; } = null!; + + public AccessRule ToAccessRule(Guid organizationId) => new() + { + OrganizationId = organizationId, + Name = Name, + Description = Description, + Conditions = SerializeConditions(Conditions), + SingleActiveLease = SingleActiveLease, + DefaultLeaseDurationSeconds = DefaultLeaseDurationSeconds, + MaxLeaseDurationSeconds = MaxLeaseDurationSeconds, + Enabled = Enabled, + AllowsExtensions = AllowsExtensions, + MaxExtensionDurationSeconds = MaxExtensionDurationSeconds, + }; + + private static string SerializeConditions(object conditions) => conditions switch + { + JsonElement je => je.GetRawText(), + _ => JsonSerializer.Serialize(conditions), + }; } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs index 9c978ebc57a2..e3012add722b 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs @@ -1,87 +1,120 @@ using System.Text.Json; using Bit.HttpExtensions; +using Bit.Pam.Models; namespace Bit.Services.Pam.Api.Models.Response; public class AccessRuleResponseModel : ResponseModel { - public AccessRuleResponseModel() + public AccessRuleResponseModel(AccessRuleDetails rule) : base("accessRule") { + ArgumentNullException.ThrowIfNull(rule); + + Id = rule.Id; + OrganizationId = rule.OrganizationId; + Name = rule.Name; + Description = rule.Description; + Enabled = rule.Enabled; + Conditions = TryParseConditions(rule.Conditions); + SingleActiveLease = rule.SingleActiveLease; + DefaultLeaseDurationSeconds = rule.DefaultLeaseDurationSeconds; + MaxLeaseDurationSeconds = rule.MaxLeaseDurationSeconds; + AllowsExtensions = rule.AllowsExtensions; + MaxExtensionDurationSeconds = rule.MaxExtensionDurationSeconds; + Collections = rule.CollectionIds.ToList(); + CreationDate = rule.CreationDate.AsUtc(); + RevisionDate = rule.RevisionDate.AsUtc(); } /// /// The rule's unique identifier. /// - public Guid Id { get; set; } + public Guid Id { get; } /// /// The organization this rule belongs to. /// - public Guid OrganizationId { get; set; } + public Guid OrganizationId { get; } /// /// The rule's display name, shown wherever rules are listed and managed. /// - public string Name { get; set; } = null!; + public string Name { get; } /// /// Optional free-text describing the rule's intent. Has no effect on evaluation; surfaced to admins only. /// - public string? Description { get; set; } + public string? Description { get; } /// /// When false, the rule is inactive and does not gate access for the collections it governs. /// - public bool Enabled { get; set; } + public bool Enabled { get; } /// - /// The condition tree that decides how access is granted under this rule — for example requiring human - /// approval, or restricting to certain times of day or source IPs. Returned as a JSON object; null when the - /// rule imposes no conditions. + /// The conditions that decide how access is granted under this rule — for example requiring human + /// approval, or restricting to certain times of day or source IPs. Returned as a JSON array of condition + /// objects; an empty array (or null) means the rule imposes no conditions. /// - public JsonElement? Conditions { get; set; } + public JsonElement? Conditions { get; } /// /// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users). /// - public bool SingleActiveLease { get; set; } + public bool SingleActiveLease { get; } /// /// Default lease duration in seconds, used to pre-fill a request opened under this rule. Null means the /// backend default applies. /// - public int? DefaultLeaseDurationSeconds { get; set; } + public int? DefaultLeaseDurationSeconds { get; } /// /// Hard ceiling on the duration of any single lease granted under this rule, in seconds. Null means no /// per-rule cap. /// - public int? MaxLeaseDurationSeconds { get; set; } + public int? MaxLeaseDurationSeconds { get; } /// /// 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; set; } + public bool AllowsExtensions { get; } /// /// The longest a single extension may run, in seconds. Set when is true. /// - public int? MaxExtensionDurationSeconds { get; set; } + public int? MaxExtensionDurationSeconds { get; } /// /// The complete set of collections this rule governs. /// - public IEnumerable Collections { get; set; } = null!; + public IEnumerable Collections { get; } /// /// When the rule was created (UTC). /// - public DateTime CreationDate { get; set; } + public DateTime CreationDate { get; } /// /// When the rule was last modified (UTC). /// - public DateTime RevisionDate { get; set; } + public DateTime RevisionDate { get; } + + private static JsonElement? TryParseConditions(string? conditionsJson) + { + if (string.IsNullOrEmpty(conditionsJson)) + { + return null; + } + try + { + return JsonDocument.Parse(conditionsJson).RootElement; + } + catch (JsonException) + { + return null; + } + } } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Response/PamDateTimeExtensions.cs b/bitwarden_license/src/Services/Pam/Api/Models/Response/PamDateTimeExtensions.cs new file mode 100644 index 000000000000..5ad29d61cefd --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Response/PamDateTimeExtensions.cs @@ -0,0 +1,23 @@ +namespace Bit.Services.Pam.Api.Models.Response; + +/// +/// Marks PAM response timestamps as UTC for serialization. +/// +/// PAM entities and read models are materialised by Dapper, which leaves their as +/// . System.Text.Json then writes an unspecified-kind value with no timezone +/// designator (e.g. "2026-06-15T13:00:00"), which a JavaScript client parses as local time. For any +/// client east/west of UTC the instant shifts — and in the approver inbox that shift drops still-valid requests whose +/// requested window only appears to have lapsed. +/// +/// The stored values are already UTC instants (the commands stamp them from UtcNow), so we relabel the kind +/// with . We deliberately do not use ToUniversalTime(), which treats an +/// unspecified value as local and would shift the clock. This mirrors the convention in CipherRepository, which +/// specifies UTC on the dates it returns. +/// +internal static class PamDateTimeExtensions +{ + public static DateTime AsUtc(this DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc); + + public static DateTime? AsUtc(this DateTime? value) => + value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; +} diff --git a/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs b/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs new file mode 100644 index 000000000000..9791bfe473cf --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Bit.Services.Pam.Models.Conditions; + +namespace Bit.Services.Pam.Enums; + +/// +/// A day of the week used in a window. Values align with +/// (Sunday = 0) so the engine can compare directly. Serialized as the lowercase +/// three-letter tokens ("sun".."sat") via . +/// +[JsonConverter(typeof(AccessWeekdayJsonConverter))] +public enum AccessWeekday : byte +{ + Sun = 0, + Mon = 1, + Tue = 2, + Wed = 3, + Thu = 4, + Fri = 5, + Sat = 6, +} diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs new file mode 100644 index 000000000000..1cc88d98a4a3 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Bit.Services.Pam.Models.Conditions; + +/// +/// Base type for a single leaf condition in an access rule's flat conditions list. Polymorphic deserialization is +/// keyed by the JSON kind property. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")] +[JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")] +[JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")] +public abstract class AccessCondition; diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs new file mode 100644 index 000000000000..16c735496add --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Services.Pam.Enums; + +namespace Bit.Services.Pam.Models.Conditions; + +/// +/// (De)serializes as the lowercase three-letter tokens the conditions JSON uses +/// ("sun".."sat"), keeping the wire format stable while the value is strongly typed in C#. This is the +/// single source of truth for the accepted day vocabulary; an unknown token fails closed with a +/// . +/// +public sealed class AccessWeekdayJsonConverter : JsonConverter +{ + private static readonly IReadOnlyDictionary _fromToken = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sun"] = AccessWeekday.Sun, + ["mon"] = AccessWeekday.Mon, + ["tue"] = AccessWeekday.Tue, + ["wed"] = AccessWeekday.Wed, + ["thu"] = AccessWeekday.Thu, + ["fri"] = AccessWeekday.Fri, + ["sat"] = AccessWeekday.Sat, + }; + + public override AccessWeekday Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String && + _fromToken.TryGetValue(reader.GetString()!, out var day)) + { + return day; + } + + throw new JsonException("Invalid day. Expected one of: sun, mon, tue, wed, thu, fri, sat."); + } + + public override void Write(Utf8JsonWriter writer, AccessWeekday value, JsonSerializerOptions options) => + writer.WriteStringValue(ToToken(value)); + + private static string ToToken(AccessWeekday day) => day switch + { + AccessWeekday.Sun => "sun", + AccessWeekday.Mon => "mon", + AccessWeekday.Tue => "tue", + AccessWeekday.Wed => "wed", + AccessWeekday.Thu => "thu", + AccessWeekday.Fri => "fri", + AccessWeekday.Sat => "sat", + _ => throw new ArgumentOutOfRangeException(nameof(day), day, null), + }; +} diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs new file mode 100644 index 000000000000..d7a7b4f7e288 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs @@ -0,0 +1,6 @@ +namespace Bit.Services.Pam.Models.Conditions; + +/// +/// Always requires a human decision before a lease can be issued. +/// +public sealed class HumanApprovalCondition : AccessCondition; diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs new file mode 100644 index 000000000000..fd63567945cc --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs @@ -0,0 +1,9 @@ +namespace Bit.Services.Pam.Models.Conditions; + +/// +/// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies. +/// +public sealed class IpAllowlistCondition : AccessCondition +{ + public IReadOnlyList Cidrs { get; init; } = []; +} diff --git a/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs new file mode 100644 index 000000000000..6b6a33bd1d0a --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs @@ -0,0 +1,19 @@ +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. +/// +public sealed class TimeOfDayCondition : AccessCondition +{ + public string Tz { get; init; } = string.Empty; + public IReadOnlyList Windows { get; init; } = []; +} + +public sealed class TimeWindow +{ + public IReadOnlyList Days { get; init; } = []; + public string From { get; init; } = string.Empty; + public string To { get; init; } = string.Empty; +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs new file mode 100644 index 000000000000..966072e9831d --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.OrganizationFeatures.Commands; + +public class CreateAccessRuleCommand : ICreateAccessRuleCommand +{ + private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; + private readonly IAccessRuleValidator _validator; + private readonly TimeProvider _timeProvider; + + public CreateAccessRuleCommand( + IAccessRuleRepository repository, + ICollectionRepository collectionRepository, + IAccessRuleValidator validator, + TimeProvider timeProvider) + { + _repository = repository; + _collectionRepository = collectionRepository; + _validator = validator; + _timeProvider = timeProvider; + } + + public async Task CreateAsync(AccessRule rule, IEnumerable collectionIds) + { + if (string.IsNullOrWhiteSpace(rule.Name)) + { + throw new BadRequestException("Name is required."); + } + + if (rule.AllowsExtensions && rule.MaxExtensionDurationSeconds is not > 0) + { + throw new BadRequestException("A maximum extension length is required when extensions are allowed."); + } + + var validation = _validator.Validate(rule.Conditions); + if (!validation.IsValid) + { + throw new BadRequestException(validation.Error!); + } + + var existing = await _repository.GetManyByOrganizationIdAsync(rule.OrganizationId); + if (existing.Any(p => string.Equals(p.Name, rule.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new BadRequestException("A rule with that name already exists."); + } + + var desiredCollectionIds = await ValidateCollectionsAsync(rule.OrganizationId, collectionIds); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + rule.CreationDate = now; + rule.RevisionDate = now; + + var created = await _repository.CreateAsync(rule); + + await _repository.SetCollectionAssociationsAsync( + created.OrganizationId, created.Id, desiredCollectionIds, []); + + return AccessRuleDetails.From(created, desiredCollectionIds); + } + + private async Task> ValidateCollectionsAsync(Guid organizationId, IEnumerable collectionIds) + { + var distinctIds = collectionIds.Distinct().ToList(); + if (distinctIds.Count == 0) + { + return distinctIds; + } + + var collections = await _collectionRepository.GetManyByManyIdsAsync(distinctIds); + if (collections.Count != distinctIds.Count) + { + throw new BadRequestException("One or more collections could not be found."); + } + + if (collections.Any(c => c.OrganizationId != organizationId)) + { + throw new BadRequestException("One or more collections do not belong to this organization."); + } + + // Deletes clear Collection.AccessRuleId and the FK forbids dangling links, so any set link points at an + // existing rule. A new rule has no Id yet, so any association is a conflict. + if (collections.Any(c => c.AccessRuleId.HasValue)) + { + throw new BadRequestException("One or more collections are already governed by another access rule."); + } + + return distinctIds; + } +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs new file mode 100644 index 000000000000..d3eb327b1047 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -0,0 +1,27 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; + +namespace Bit.Services.Pam.OrganizationFeatures.Commands; + +public class DeleteAccessRuleCommand : IDeleteAccessRuleCommand +{ + private readonly IAccessRuleRepository _repository; + + public DeleteAccessRuleCommand(IAccessRuleRepository repository) + { + _repository = repository; + } + + public async Task DeleteAsync(Guid organizationId, Guid id, Guid? deletedBy) + { + var existing = await _repository.GetByIdAsync(id); + if (existing is null || existing.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + // Hard delete: remove the rule and clear its collection links (they become ungoverned). + await _repository.DeleteAsync(existing); + } +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs new file mode 100644 index 000000000000..7de23490dc80 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs @@ -0,0 +1,12 @@ +using Bit.Pam.Entities; +using Bit.Pam.Models; + +namespace Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface ICreateAccessRuleCommand +{ + /// + /// Creates an access rule and associates exactly the given collections with it. + /// + Task CreateAsync(AccessRule rule, IEnumerable collectionIds); +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs new file mode 100644 index 000000000000..0a490a96b092 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IDeleteAccessRuleCommand +{ + Task DeleteAsync(Guid organizationId, Guid id, Guid? deletedBy); +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs new file mode 100644 index 000000000000..3047a8ec3ae7 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs @@ -0,0 +1,12 @@ +using Bit.Pam.Entities; +using Bit.Pam.Models; + +namespace Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; + +public interface IUpdateAccessRuleCommand +{ + /// + /// Updates an access rule and replaces its collection associations with exactly the given collections. + /// + Task UpdateAsync(Guid organizationId, Guid id, AccessRule update, IEnumerable collectionIds); +} diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs new file mode 100644 index 000000000000..f1587d5847fc --- /dev/null +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -0,0 +1,120 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Services.Pam.Services; + +namespace Bit.Services.Pam.OrganizationFeatures.Commands; + +public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand +{ + private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; + private readonly IAccessRuleValidator _validator; + private readonly TimeProvider _timeProvider; + + public UpdateAccessRuleCommand( + IAccessRuleRepository repository, + ICollectionRepository collectionRepository, + IAccessRuleValidator validator, + TimeProvider timeProvider) + { + _repository = repository; + _collectionRepository = collectionRepository; + _validator = validator; + _timeProvider = timeProvider; + } + + public async Task UpdateAsync(Guid organizationId, Guid id, AccessRule update, + IEnumerable collectionIds) + { + if (string.IsNullOrWhiteSpace(update.Name)) + { + throw new BadRequestException("Name is required."); + } + + if (update.AllowsExtensions && update.MaxExtensionDurationSeconds is not > 0) + { + throw new BadRequestException("A maximum extension length is required when extensions are allowed."); + } + + var existing = await _repository.GetDetailsByIdAsync(id); + if (existing is null || existing.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var validation = _validator.Validate(update.Conditions); + if (!validation.IsValid) + { + throw new BadRequestException(validation.Error!); + } + + var siblings = await _repository.GetManyByOrganizationIdAsync(organizationId); + if (siblings.Any(p => p.Id != id && string.Equals(p.Name, update.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new BadRequestException("A rule with that name already exists."); + } + + var desiredCollectionIds = await ValidateCollectionsAsync(organizationId, id, collectionIds); + + // Persist a plain AccessRule: the AccessRuleDetails returned by GetDetailsByIdAsync carries an extra + // CollectionIds property that the base ReplaceAsync would otherwise forward to AccessRule_Update. + var toPersist = new AccessRule + { + Id = existing.Id, + OrganizationId = existing.OrganizationId, + Name = update.Name, + Description = update.Description, + Conditions = update.Conditions, + SingleActiveLease = update.SingleActiveLease, + DefaultLeaseDurationSeconds = update.DefaultLeaseDurationSeconds, + MaxLeaseDurationSeconds = update.MaxLeaseDurationSeconds, + Enabled = update.Enabled, + AllowsExtensions = update.AllowsExtensions, + MaxExtensionDurationSeconds = update.MaxExtensionDurationSeconds, + CreationDate = existing.CreationDate, + RevisionDate = _timeProvider.GetUtcNow().UtcDateTime, + LastEditedBy = update.LastEditedBy, + }; + + await _repository.ReplaceAsync(toPersist); + + var toClear = existing.CollectionIds.Except(desiredCollectionIds).ToList(); + await _repository.SetCollectionAssociationsAsync(organizationId, id, desiredCollectionIds, toClear); + + return AccessRuleDetails.From(toPersist, desiredCollectionIds); + } + + private async Task> ValidateCollectionsAsync(Guid organizationId, Guid accessRuleId, + IEnumerable collectionIds) + { + var distinctIds = collectionIds.Distinct().ToList(); + if (distinctIds.Count == 0) + { + return distinctIds; + } + + var collections = await _collectionRepository.GetManyByManyIdsAsync(distinctIds); + if (collections.Count != distinctIds.Count) + { + throw new BadRequestException("One or more collections could not be found."); + } + + if (collections.Any(c => c.OrganizationId != organizationId)) + { + throw new BadRequestException("One or more collections do not belong to this organization."); + } + + // Deletes clear Collection.AccessRuleId and the FK forbids dangling links, so any set link points at an + // existing rule; only a link to a different rule is a conflict. + if (collections.Any(c => c.AccessRuleId.HasValue && c.AccessRuleId != accessRuleId)) + { + throw new BadRequestException("One or more collections are already governed by another access rule."); + } + + return distinctIds; + } +} diff --git a/bitwarden_license/src/Services/Pam/Pam.csproj b/bitwarden_license/src/Services/Pam/Pam.csproj index 264ff0119b1a..f148f9c30f60 100644 --- a/bitwarden_license/src/Services/Pam/Pam.csproj +++ b/bitwarden_license/src/Services/Pam/Pam.csproj @@ -26,6 +26,7 @@ + diff --git a/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs new file mode 100644 index 000000000000..f4a48061991f --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bit.Services.Pam.Models.Conditions; + +namespace Bit.Services.Pam.Services; + +public sealed partial 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) + { + return AccessRuleValidationResult.Valid; + } + + if (string.IsNullOrWhiteSpace(conditionsJson)) + { + return AccessRuleValidationResult.Invalid("Conditions JSON cannot be empty."); + } + + List? conditions; + try + { + conditions = JsonSerializer.Deserialize>(conditionsJson, JsonOptions); + } + catch (JsonException ex) + { + return AccessRuleValidationResult.Invalid($"Conditions JSON is malformed: {ex.Message}"); + } + + if (conditions is null) + { + return AccessRuleValidationResult.Invalid("Conditions must be an array."); + } + + // An empty list is allowed: it is vacuously satisfied, so the rule governs its collections — routing access + // through the PAM flow for audit logging — without imposing any gating condition. The engine evaluates it + // to Allow. + if (conditions.Count > MaxConditions) + { + return AccessRuleValidationResult.Invalid($"Conditions cannot contain more than {MaxConditions} conditions."); + } + + 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; + } +} diff --git a/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs b/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs new file mode 100644 index 000000000000..b706ccee6f8c --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs @@ -0,0 +1,16 @@ +namespace Bit.Services.Pam.Services; + +public interface IAccessRuleValidator +{ + /// + /// Validates a raw JSON conditions document. A null or empty document is treated as "no conditions + /// configured" and considered valid; callers decide how to treat that semantically. + /// + 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/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 2dfd4a931641..8c055f68b44e 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,10 @@ using Bit.HttpExtensions; using Bit.Services.Pam.Api.Endpoints; using Bit.Services.Pam.Api.Endpoints.Handlers; +using Bit.Services.Pam.OrganizationFeatures.Commands; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Services.Pam.Services; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bit.Services.Pam.Utilities; @@ -13,6 +17,13 @@ public static IServiceCollection AddPamServices(this IServiceCollection services services.AddScoped(); services.AddScoped(); + // AccessRule write path. + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddPamOpenApiEndpointDataSource(); return services; diff --git a/bitwarden_license/src/Services/Pam/packages.lock.json b/bitwarden_license/src/Services/Pam/packages.lock.json index 7c0020671190..dd1842803698 100644 --- a/bitwarden_license/src/Services/Pam/packages.lock.json +++ b/bitwarden_license/src/Services/Pam/packages.lock.json @@ -287,10 +287,10 @@ }, "MailKit": { "type": "Transitive", - "resolved": "4.16.0", - "contentHash": "trJ82DOpAmo8i1jO1vNE+dGn4mPRyeYfy4swRcAGgMJhPoI1Kohf4OFJJf0+YIj4iUxgxPn8W+ht7e7KiYzSjg==", + "resolved": "4.17.0", + "contentHash": "1nUAVLxM9fhT/78we6/AGsCesnpn5dRNLLeRqOfr52Wnk87pzVwo5YMTMyqnmoXrYc7piGhmayiMA/OgDluIjg==", "dependencies": { - "MimeKit": "4.16.0" + "MimeKit": "4.17.0" } }, "Microsoft.AspNetCore.Authentication.JwtBearer": { @@ -590,8 +590,8 @@ }, "MimeKit": { "type": "Transitive", - "resolved": "4.16.0", - "contentHash": "X0LFxeM4gPRIhODyY/HYS9b+zRZ7y//v59rFzgS6wLxcPuZThnMtNZHtrr0fjLyRRkg3gqJBtvW36XfUzZ7Djw==", + "resolved": "4.17.0", + "contentHash": "h/KXsCreJf8RpR/PSAtlbvYtZFndp9N8Wn1Bs248AL7ITyhn+AiIY5bLTvt60tbN2mu6g8KadtBNWygTji2lqg==", "dependencies": { "BouncyCastle.Cryptography": "2.6.2", "System.Security.Cryptography.Pkcs": "10.0.0" @@ -843,13 +843,14 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", "Fido2.AspNet": "[3.0.1, 3.0.1]", "Handlebars.Net": "[2.1.6, 2.1.6]", "LaunchDarkly.ServerSdk": "[8.11.0, 8.11.0]", - "MailKit": "[4.16.0, 4.16.0]", + "MailKit": "[4.17.0, 4.17.0]", "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.8, 10.0.8]", "Microsoft.AspNetCore.DataProtection": "[10.0.8, 10.0.8]", "Microsoft.Azure.Cosmos": "[3.52.0, 3.52.0]", @@ -867,6 +868,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -880,8 +882,17 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "data": { + "type": "Project" + }, "httpextensions": { "type": "Project" + }, + "pam.domain": { + "type": "Project", + "dependencies": { + "Data": "[2026.6.2, )" + } } } } diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs new file mode 100644 index 000000000000..6d15d453a279 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs @@ -0,0 +1,263 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.Commands; + +[SutProviderCustomize] +public class CreateAccessRuleCommandTests +{ + private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "VPN + business hours"; + rule.Conditions = """{"kind":"human_approval"}"""; + rule.DefaultLeaseDurationSeconds = 3600; + rule.MaxLeaseDurationSeconds = 28800; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + + var result = await sutProvider.Sut.CreateAsync(rule, []); + + Assert.Equal(_now, result.CreationDate); + Assert.Equal(_now, result.RevisionDate); + Assert.Equal(3600, result.DefaultLeaseDurationSeconds); + Assert.Equal(28800, result.MaxLeaseDurationSeconds); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(r => + r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRule rule, Collection collectionA, + Collection collectionB) + { + var sutProvider = SetupSutProvider(); + rule.Name = "VPN + business hours"; + rule.Conditions = """{"kind":"human_approval"}"""; + collectionA.OrganizationId = rule.OrganizationId; + collectionA.AccessRuleId = null; + collectionB.OrganizationId = rule.OrganizationId; + collectionB.AccessRuleId = null; + var collectionIds = new[] { collectionA.Id, collectionB.Id }; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x)))) + .Returns(new List { collectionA, collectionB }); + + await sutProvider.Sut.CreateAsync(rule, collectionIds); + + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(rule.OrganizationId, rule.Id, + Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x))), + Arg.Is>(ids => !ids.Any())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionInDifferentOrg_ThrowsBadRequest(AccessRule rule, Collection collection) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Conditions = """{"kind":"human_approval"}"""; + collection.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id })); + Assert.Contains("do not belong to this organization", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest( + AccessRule rule, AccessRule otherRule, Collection collection) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Conditions = """{"kind":"human_approval"}"""; + otherRule.OrganizationId = rule.OrganizationId; + otherRule.Name = "other"; + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = otherRule.Id; // governed by another rule + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { otherRule }); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { collection.Id })); + Assert.Contains("already governed by another access rule", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CollectionNotFound_ThrowsBadRequest(AccessRule rule, Guid missingCollectionId) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Conditions = """{"kind":"human_approval"}"""; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List()); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(rule, new[] { missingCollectionId })); + Assert.Contains("could not be found", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_EmptyName_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = " "; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("Name is required", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_InvalidRule_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "test"; + rule.Conditions = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Invalid("Unsupported rule kind")); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Equal("Unsupported rule kind", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, AccessRule existing) + { + var sutProvider = SetupSutProvider(); + rule.Name = "duplicate"; + rule.Conditions = """{"kind":"human_approval"}"""; + existing.OrganizationId = rule.OrganizationId; + existing.Name = "Duplicate"; // case-insensitive collision + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { existing }); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("already exists", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensionDurationSeconds = null; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("maximum extension length", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task CreateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensionDurationSeconds, AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensionDurationSeconds = maxExtensionDurationSeconds; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Contains("maximum extension length", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + } + + [Theory, BitAutoData] + public async Task CreateAsync_AllowsExtensionsWithPositiveMax_Persists(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + rule.Name = "extendable"; + rule.Conditions = """{"kind":"human_approval"}"""; + rule.AllowsExtensions = true; + rule.MaxExtensionDurationSeconds = 3600; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + sutProvider.GetDependency() + .CreateAsync(rule) + .Returns(rule); + + var result = await sutProvider.Sut.CreateAsync(rule, []); + + Assert.True(result.AllowsExtensions); + Assert.Equal(3600, result.MaxExtensionDurationSeconds); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(r => r.AllowsExtensions && r.MaxExtensionDurationSeconds == 3600)); + } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs new file mode 100644 index 000000000000..886aacb90f0a --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.Commands; + +[SutProviderCustomize] +public class DeleteAccessRuleCommandTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_HappyPath_HardDeletes( + AccessRule existing, Guid deletedBy, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + + await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id, deletedBy); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(existing); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_MissingExisting_ThrowsNotFound( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((AccessRule?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid())); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WrongOrg_ThrowsNotFound( + AccessRule existing, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(existing.Id) + .Returns(existing); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id, Guid.NewGuid())); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().DeleteAsync(default!); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs new file mode 100644 index 000000000000..656b5ef5c21e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs @@ -0,0 +1,244 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.OrganizationFeatures.Commands; +using Bit.Services.Pam.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.Commands; + +[SutProviderCustomize] +public class UpdateAccessRuleCommandTests +{ + private static readonly DateTime _now = new(2026, 5, 21, 12, 0, 0, DateTimeKind.Utc); + + [Theory, BitAutoData] + public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRuleDetails existing, AccessRule update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + existing.CollectionIds = []; + update.Name = "renamed"; + update.Description = "new description"; + update.Conditions = """{"kind":"human_approval"}"""; + update.SingleActiveLease = true; + update.DefaultLeaseDurationSeconds = 3600; + update.MaxLeaseDurationSeconds = 28800; + update.AllowsExtensions = true; + update.MaxExtensionDurationSeconds = 7200; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); + + Assert.Equal("renamed", result.Name); + Assert.Equal("new description", result.Description); + Assert.Equal(update.Conditions, result.Conditions); + Assert.True(result.SingleActiveLease); + Assert.Equal(3600, result.DefaultLeaseDurationSeconds); + Assert.Equal(28800, result.MaxLeaseDurationSeconds); + Assert.True(result.AllowsExtensions); + Assert.Equal(7200, result.MaxExtensionDurationSeconds); + Assert.Equal(_now, result.RevisionDate); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(r => + r.Id == existing.Id && r.Name == "renamed" && r.Description == "new description" + && r.SingleActiveLease + && r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800 + && r.AllowsExtensions && r.MaxExtensionDurationSeconds == 7200)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule update) + { + var sutProvider = SetupSutProvider(); + update.Name = "renamed"; + update.AllowsExtensions = true; + update.MaxExtensionDurationSeconds = null; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); + Assert.Contains("maximum extension length", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task UpdateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest(int maxExtensionDurationSeconds, AccessRule update) + { + var sutProvider = SetupSutProvider(); + update.Name = "renamed"; + update.AllowsExtensions = true; + update.MaxExtensionDurationSeconds = maxExtensionDurationSeconds; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(update.OrganizationId, update.Id, update, [])); + Assert.Contains("maximum extension length", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(AccessRuleDetails existing, + AccessRule update, Collection keep, Collection add) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Conditions = """{"kind":"human_approval"}"""; + keep.OrganizationId = orgId; + keep.AccessRuleId = existing.Id; // already governed by this rule + add.OrganizationId = orgId; + add.AccessRuleId = null; + var desired = new[] { keep.Id, add.Id }; + var removedId = Guid.NewGuid(); + existing.CollectionIds = [keep.Id, removedId]; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { keep, add }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, desired); + + Assert.Equal(desired, result.CollectionIds); + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(orgId, existing.Id, + Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(desired.OrderBy(x => x))), + Arg.Is>(ids => ids.SequenceEqual(new[] { removedId }))); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EmptyCollections_ClearsAll(AccessRuleDetails existing, AccessRule update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Conditions = """{"kind":"human_approval"}"""; + var currentId = Guid.NewGuid(); + existing.CollectionIds = [currentId]; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing }); + + var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); + + Assert.Empty(result.CollectionIds); + await sutProvider.GetDependency().Received(1) + .SetCollectionAssociationsAsync(orgId, existing.Id, + Arg.Is>(ids => !ids.Any()), + Arg.Is>(ids => ids.SequenceEqual(new[] { currentId }))); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_CollectionGovernedByAnotherRule_ThrowsBadRequest(AccessRuleDetails existing, + AccessRule update, AccessRule otherRule, Collection collection) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "renamed"; + update.Conditions = """{"kind":"human_approval"}"""; + otherRule.OrganizationId = orgId; + otherRule.Name = "other"; + collection.OrganizationId = orgId; + collection.AccessRuleId = otherRule.Id; // a different rule + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List { existing, otherRule }); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, new[] { collection.Id })); + Assert.Contains("already governed by another access rule", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_MissingExisting_ThrowsNotFound(AccessRule update) + { + var sutProvider = SetupSutProvider(); + sutProvider.GetDependency() + .GetDetailsByIdAsync(Arg.Any()) + .Returns((AccessRuleDetails?)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update, [])); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WrongOrg_ThrowsNotFound(AccessRuleDetails existing, AccessRule update) + { + var sutProvider = SetupSutProvider(); + var differentOrg = Guid.NewGuid(); + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(differentOrg, existing.Id, update, [])); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRuleDetails existing, AccessRule update) + { + var sutProvider = SetupSutProvider(); + var orgId = existing.OrganizationId; + update.Name = "ok"; + update.Conditions = """{"kind":"bogus"}"""; + sutProvider.GetDependency() + .GetDetailsByIdAsync(existing.Id) + .Returns(existing); + sutProvider.GetDependency() + .Validate(update.Conditions) + .Returns(AccessRuleValidationResult.Invalid("nope")); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, [])); + Assert.Equal("nope", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_now); + return sutProvider; + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj b/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj index 776467840305..0e79dabb563d 100644 --- a/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj +++ b/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj @@ -19,6 +19,7 @@ + diff --git a/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs new file mode 100644 index 000000000000..aaededee765c --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs @@ -0,0 +1,173 @@ +using Bit.Services.Pam.Services; +using Xunit; + +namespace Bit.Services.Pam.Test.Services; + +public class AccessRuleValidatorTests +{ + private readonly AccessRuleValidator _sut = new(); + + [Fact] + public void Validate_NullConditions_IsValid() + { + var result = _sut.Validate(null); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Validate_EmptyOrWhitespaceConditions_IsInvalid(string conditionsJson) + { + var result = _sut.Validate(conditionsJson); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_MalformedJson_IsInvalid() + { + var result = _sut.Validate("{not json"); + + Assert.False(result.IsValid); + Assert.Contains("malformed", result.Error); + } + + [Fact] + public void Validate_NonArrayDocument_IsInvalid() + { + // The conditions document is a flat array; a bare object is rejected. + var result = _sut.Validate("""{"kind":"human_approval"}"""); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_UnknownKind_IsInvalid() + { + var result = _sut.Validate("""[{"kind":"bogus"}]"""); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_LegacyAllOfKind_IsInvalid() + { + // The flattened model dropped the all_of composite; a document that still nests one is rejected rather than + // silently accepted. + var result = _sut.Validate("""[{"kind":"all_of","conditions":[]}]"""); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_HumanApproval_IsValid() + { + 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); + + Assert.False(result.IsValid); + Assert.Contains(expectedMessageFragment, result.Error); + } + + [Fact] + public void Validate_MultipleConditions_IsValid() + { + var result = _sut.Validate(""" + [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] } + ] + """); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_EmptyConditions_IsValid() + { + // A rule with no conditions is allowed: it gates nothing and exists to route access through the PAM flow + // for audit logging. + var result = _sut.Validate("[]"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ExceedsMaxConditions_IsInvalid() + { + var conditions = string.Join(",", Enumerable.Repeat("""{"kind":"human_approval"}""", 11)); + var result = _sut.Validate($$"""[{{conditions}}]"""); + + Assert.False(result.IsValid); + Assert.Contains("more than", result.Error); + } + + [Fact] + public void Validate_InvalidCondition_IsInvalid() + { + var result = _sut.Validate(""" + [ + { "kind": "human_approval" }, + { "kind": "ip_allowlist", "cidrs": ["bogus"] } + ] + """); + + Assert.False(result.IsValid); + Assert.Contains("CIDR", result.Error); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/packages.lock.json b/bitwarden_license/test/Services/Pam.Test/packages.lock.json index 603b17d76ff9..6dc200bb4235 100644 --- a/bitwarden_license/test/Services/Pam.Test/packages.lock.json +++ b/bitwarden_license/test/Services/Pam.Test/packages.lock.json @@ -63,6 +63,32 @@ "StackExchange.Redis": "2.6.80" } }, + "AutoFixture": { + "type": "Transitive", + "resolved": "4.18.1", + "contentHash": "BmWZDY4fkrYOyd5/CTBOeXbzsNwV8kI4kDi/Ty1Y5F+WDHBVKxzfWlBE4RSicvZ+EOi2XDaN5uwdrHsItLW6Kw==", + "dependencies": { + "Fare": "[2.1.1, 3.0.0)" + } + }, + "AutoFixture.AutoNSubstitute": { + "type": "Transitive", + "resolved": "4.18.1", + "contentHash": "xJxIsShO/1Ceei7BDFCobFANiw5a+enpdklgX/Xic6vKavHo9gSJO7ZGkKlf2lh+TlblTEet9mjzf9wHWIqWGQ==", + "dependencies": { + "AutoFixture": "4.18.1", + "NSubstitute": "[2.0.3, 6.0.0)" + } + }, + "AutoFixture.Xunit2": { + "type": "Transitive", + "resolved": "4.18.1", + "contentHash": "I5Cwv1bvWb0lf2x2zO42bBQ2WaGudBh7tVBCzKIf8KmRJG+hmYY7ku3znnFZDVxbQaihNaqNkztLTwK4PwaoWg==", + "dependencies": { + "AutoFixture": "4.18.1", + "xunit.extensibility.core": "[2.2.0, 3.0.0)" + } + }, "AWSSDK.Core": { "type": "Transitive", "resolved": "4.0.3.3", @@ -201,6 +227,14 @@ "System.Xml.XPath.XmlDocument": "4.3.0" } }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, "CsvHelper": { "type": "Transitive", "resolved": "33.1.0", @@ -242,6 +276,14 @@ "Microsoft.IdentityModel.JsonWebTokens": "6.34.0" } }, + "Fare": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "HaI8puqA66YU7/9cK4Sgbs1taUTP1Ssa4QT2PIzqJ7GvAbN1QgkjbRsjH+FSbMh1MJdvS0CIwQNLtFT+KF6KpA==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, "Fido2": { "type": "Transitive", "resolved": "3.0.1", @@ -273,6 +315,15 @@ "resolved": "2.1.6", "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" }, + "Kralizek.AutoFixture.Extensions.MockHttp": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "yNpYOT8k6L9PVS2YPoAe72IjILqGfPixKDzPsAFMz2aVyrmgGjirqORQa+bQNe+Qs5ytB+p41uzy4F9mjUuP9w==", + "dependencies": { + "AutoFixture": "4.18.1", + "RichardSzalay.MockHttp": "7.0.0" + } + }, "LaunchDarkly.Cache": { "type": "Transitive", "resolved": "1.0.2", @@ -330,10 +381,10 @@ }, "MailKit": { "type": "Transitive", - "resolved": "4.16.0", - "contentHash": "trJ82DOpAmo8i1jO1vNE+dGn4mPRyeYfy4swRcAGgMJhPoI1Kohf4OFJJf0+YIj4iUxgxPn8W+ht7e7KiYzSjg==", + "resolved": "4.17.0", + "contentHash": "1nUAVLxM9fhT/78we6/AGsCesnpn5dRNLLeRqOfr52Wnk87pzVwo5YMTMyqnmoXrYc7piGhmayiMA/OgDluIjg==", "dependencies": { - "MimeKit": "4.16.0" + "MimeKit": "4.17.0" } }, "Microsoft.AspNetCore.Authentication.JwtBearer": { @@ -594,6 +645,15 @@ "StackExchange.Redis": "2.7.27" } }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.6.0", + "contentHash": "L8zTKn8e2LCQbsDFLWFm6fZQ54F/1FisLx43nkEof4HmmsO2HaZHshV85+qF8HXO48MlGJdrWUg+uVBj/WDmmw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.ObjectPool": "10.0.8" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.8", @@ -695,6 +755,16 @@ "Microsoft.Extensions.Options": "10.0.8" } }, + "Microsoft.Extensions.Diagnostics.Testing": { + "type": "Transitive", + "resolved": "10.6.0", + "contentHash": "WFgkep0Nxz0aht9k/OKwXdBOZ/uIB8VULY35ou91BiK4k0gj9CJz985T8GN0Q7XjCOMjNgjVFnv/9FmqcDEivg==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8", + "Microsoft.Extensions.Telemetry.Abstractions": "10.6.0" + } + }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", "resolved": "10.0.8", @@ -782,6 +852,11 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" } }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "aQBFbY8i/dacE0fP+ZJ8Lhx/unYRnGHhtM+tHb46GLkeNjBdOzgFk88sX6BVZBhoa6JrYIOBGYTc5K4WItBsag==" + }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "10.0.8", @@ -808,6 +883,22 @@ "resolved": "10.0.8", "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.6.0", + "contentHash": "aNQEJu5DD2YVQEWWmC/ALEiV1Qt400BaDO+SExtfAaGqYaNu/r2sW9xGLuc71fcjbrmzqX8LzNgK5mzjjMW9RQ==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.6.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.ObjectPool": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Transitive", + "resolved": "10.6.0", + "contentHash": "qQDiaYWpvIymGbu+kXaMDS8YdqfeQkv6DOxPF2GSwC+eSzIKqOOnSP34TYt7gKqvB7p8/aSptexnW6nF0CUdnw==" + }, "Microsoft.Identity.Client": { "type": "Transitive", "resolved": "4.66.1", @@ -916,8 +1007,8 @@ }, "MimeKit": { "type": "Transitive", - "resolved": "4.16.0", - "contentHash": "X0LFxeM4gPRIhODyY/HYS9b+zRZ7y//v59rFzgS6wLxcPuZThnMtNZHtrr0fjLyRRkg3gqJBtvW36XfUzZ7Djw==", + "resolved": "4.17.0", + "contentHash": "h/KXsCreJf8RpR/PSAtlbvYtZFndp9N8Wn1Bs248AL7ITyhn+AiIY5bLTvt60tbN2mu6g8KadtBNWygTji2lqg==", "dependencies": { "BouncyCastle.Cryptography": "2.6.2", "System.Security.Cryptography.Pkcs": "10.0.0" @@ -944,6 +1035,14 @@ "libsodium": "[1.0.18.2, 1.0.19)" } }, + "NSubstitute": { + "type": "Transitive", + "resolved": "5.1.0", + "contentHash": "ZCqOP3Kpp2ea7QcLyjMU4wzE+0wmrMN35PQMsdPOHYc2IrvjmusG9hICOiqiOTPKN0gJon6wyCn6ZuGHdNs9hQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, "OneOf": { "type": "Transitive", "resolved": "3.0.271", @@ -994,6 +1093,11 @@ "System.Threading.RateLimiting": "8.0.0" } }, + "RichardSzalay.MockHttp": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "QwnauYiaywp65QKFnP+wvgiQ2D8Pv888qB2dyfd7MSVDF06sIvxqASenk+RxsWybyyt+Hu1Y251wQxpHTv3UYg==" + }, "SendGrid": { "type": "Transitive", "resolved": "9.29.3", @@ -1234,11 +1338,17 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, - "pam": { + "common": { "type": "Project", "dependencies": { - "Core": "[2026.6.1, )", - "HttpExtensions": "[2026.6.1, )" + "AutoFixture.AutoNSubstitute": "[4.18.1, )", + "AutoFixture.Xunit2": "[4.18.1, )", + "Core": "[2026.6.2, )", + "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", + "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", + "Microsoft.NET.Test.Sdk": "[18.0.1, )", + "NSubstitute": "[5.1.0, )", + "xunit": "[2.6.6, )" } }, "core": { @@ -1257,13 +1367,14 @@ "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", "CsvHelper": "[33.1.0, 33.1.0]", + "Data": "[2026.6.2, )", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", "Fido2.AspNet": "[3.0.1, 3.0.1]", "Handlebars.Net": "[2.1.6, 2.1.6]", "LaunchDarkly.ServerSdk": "[8.11.0, 8.11.0]", - "MailKit": "[4.16.0, 4.16.0]", + "MailKit": "[4.17.0, 4.17.0]", "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.8, 10.0.8]", "Microsoft.AspNetCore.DataProtection": "[10.0.8, 10.0.8]", "Microsoft.Azure.Cosmos": "[3.52.0, 3.52.0]", @@ -1281,6 +1392,7 @@ "Newtonsoft.Json": "[13.0.3, 13.0.3]", "OneOf": "[3.0.271, 3.0.271]", "Otp.NET": "[1.4.0, 1.4.0]", + "Pam.Domain": "[2026.6.2, )", "Quartz": "[3.15.1, 3.15.1]", "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]", "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]", @@ -1294,8 +1406,39 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "core.test": { + "type": "Project", + "dependencies": { + "AutoFixture.AutoNSubstitute": "[4.18.1, )", + "AutoFixture.Xunit2": "[4.18.1, )", + "Common": "[2026.6.2, )", + "Core": "[2026.6.2, )", + "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", + "Microsoft.Extensions.Diagnostics.Testing": "[10.6.0, 10.6.0]", + "Microsoft.NET.Test.Sdk": "[18.0.1, )", + "NSubstitute": "[5.1.0, )", + "xunit": "[2.6.6, )" + } + }, + "data": { + "type": "Project" + }, "httpextensions": { "type": "Project" + }, + "pam": { + "type": "Project", + "dependencies": { + "Core": "[2026.6.2, )", + "HttpExtensions": "[2026.6.2, )", + "Pam.Domain": "[2026.6.2, )" + } + }, + "pam.domain": { + "type": "Project", + "dependencies": { + "Data": "[2026.6.2, )" + } } } }