From fb71907a7a4dde672d2a2a987b0eb061b70bb626 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 14 Jul 2026 13:44:47 +0200 Subject: [PATCH 01/13] Implement AccessRule endpoints handler, commands, validator, and API models --- .../Handlers/AccessRuleEndpointsHandler.cs | 88 ++++++++--- .../Models/Request/AccessRuleRequestModel.cs | 29 +++- .../Response/AccessRuleResponseModel.cs | 69 ++++++--- .../Models/Response/PamDateTimeExtensions.cs | 23 +++ .../src/Services/Pam/Enums/AccessWeekday.cs | 21 +++ .../Pam/Models/Conditions/AccessCondition.cs | 13 ++ .../Conditions/AccessWeekdayJsonConverter.cs | 52 +++++++ .../Conditions/HumanApprovalCondition.cs | 6 + .../Models/Conditions/IpAllowlistCondition.cs | 9 ++ .../Models/Conditions/TimeOfDayCondition.cs | 19 +++ .../Commands/CreateAccessRuleCommand.cs | 96 ++++++++++++ .../Commands/DeleteAccessRuleCommand.cs | 27 ++++ .../Interfaces/ICreateAccessRuleCommand.cs | 12 ++ .../Interfaces/IDeleteAccessRuleCommand.cs | 6 + .../Interfaces/IUpdateAccessRuleCommand.cs | 12 ++ .../Commands/UpdateAccessRuleCommand.cs | 120 +++++++++++++++ bitwarden_license/src/Services/Pam/Pam.csproj | 1 + .../Pam/Services/AccessRuleValidator.cs | 137 ++++++++++++++++++ .../Pam/Services/IAccessRuleValidator.cs | 16 ++ .../Utilities/ServiceCollectionExtensions.cs | 11 ++ 20 files changed, 729 insertions(+), 38 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/Api/Models/Response/PamDateTimeExtensions.cs create mode 100644 bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs create mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs create mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs create mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/HumanApprovalCondition.cs create mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/IpAllowlistCondition.cs create mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/ICreateAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IUpdateAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs create mode 100644 bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs create mode 100644 bitwarden_license/src/Services/Pam/Services/IAccessRuleValidator.cs 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 e80fe52e7579..ddec143d0dc0 100644 --- a/bitwarden_license/src/Services/Pam/Pam.csproj +++ b/bitwarden_license/src/Services/Pam/Pam.csproj @@ -27,6 +27,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 c3506ff35aa5..74404e98e7a9 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; @@ -14,6 +18,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; From 38dcb6711ec8e740473d778368f925c5ca925d16 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 14 Jul 2026 19:28:18 +0200 Subject: [PATCH 02/13] Add AccessRule command and validator tests --- .../Commands/CreateAccessRuleCommandTests.cs | 263 ++++++++++++++++++ .../Commands/DeleteAccessRuleCommandTests.cs | 56 ++++ .../Commands/UpdateAccessRuleCommandTests.cs | 244 ++++++++++++++++ .../test/Services/Pam.Test/Pam.Test.csproj | 1 + .../Services/AccessRuleValidatorTests.cs | 173 ++++++++++++ 5 files changed, 737 insertions(+) create mode 100644 bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs 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); + } +} From 4bf82d13d4f5d8b5d1b33d7e17ed2d60df7eecc3 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 27 Jul 2026 10:13:31 +0200 Subject: [PATCH 03/13] Drop the time-of-day access condition Remove the time_of_day condition kind along with the AccessWeekday enum and its JSON converter, which existed only to type the weekday tokens inside a time window. Access rules now expose human_approval and ip_allowlist; a document that still carries a time_of_day entry is rejected as an unknown kind. --- .../src/Services/Pam/Enums/AccessWeekday.cs | 21 ------- .../Pam/Models/Conditions/AccessCondition.cs | 1 - .../Conditions/AccessWeekdayJsonConverter.cs | 52 ------------------ .../Models/Conditions/TimeOfDayCondition.cs | 19 ------- .../Pam/Services/AccessRuleValidator.cs | 55 +------------------ .../Services/AccessRuleValidatorTests.cs | 33 ----------- 6 files changed, 1 insertion(+), 180 deletions(-) delete mode 100644 bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs delete mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs delete mode 100644 bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs diff --git a/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs b/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs deleted file mode 100644 index 9791bfe473cf..000000000000 --- a/bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs +++ /dev/null @@ -1,21 +0,0 @@ -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 index 1cc88d98a4a3..cf61651a1a10 100644 --- a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs +++ b/bitwarden_license/src/Services/Pam/Models/Conditions/AccessCondition.cs @@ -9,5 +9,4 @@ namespace Bit.Services.Pam.Models.Conditions; [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 deleted file mode 100644 index 16c735496add..000000000000 --- a/bitwarden_license/src/Services/Pam/Models/Conditions/AccessWeekdayJsonConverter.cs +++ /dev/null @@ -1,52 +0,0 @@ -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/TimeOfDayCondition.cs b/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs deleted file mode 100644 index 6b6a33bd1d0a..000000000000 --- a/bitwarden_license/src/Services/Pam/Models/Conditions/TimeOfDayCondition.cs +++ /dev/null @@ -1,19 +0,0 @@ -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/Services/AccessRuleValidator.cs b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs index f4a48061991f..0d1c93d29ce0 100644 --- a/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs +++ b/bitwarden_license/src/Services/Pam/Services/AccessRuleValidator.cs @@ -1,11 +1,10 @@ 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 +public sealed class AccessRuleValidator : IAccessRuleValidator { private const int MaxConditions = 10; @@ -15,9 +14,6 @@ public sealed partial class AccessRuleValidator : IAccessRuleValidator 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) @@ -63,7 +59,6 @@ private static AccessRuleValidationResult ValidateCondition(AccessCondition? con { 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}."), }; @@ -86,52 +81,4 @@ private static AccessRuleValidationResult ValidateIpAllowlist(IpAllowlistConditi 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/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs index aaededee765c..22c9cdc13afc 100644 --- a/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleValidatorTests.cs @@ -91,39 +91,6 @@ public void Validate_IpAllowlist_InvalidCidrs_IsInvalid(string conditionsJson, s 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() { From 9294cbc2fabec9068d3369108e2abaa4de59db0d Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 27 Jul 2026 13:20:15 +0200 Subject: [PATCH 04/13] Update comments --- .../Pam/Api/Models/Request/AccessRuleRequestModel.cs | 8 ++++---- .../Pam/Api/Models/Response/AccessRuleResponseModel.cs | 6 +++--- bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) 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 101ed8729712..03388e7c62bb 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs @@ -25,10 +25,10 @@ public class AccessRuleRequestModel public bool Enabled { get; set; } = true; /// - /// 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. + /// The conditions that decide how access is granted under this rule — for example requiring human approval, + /// or restricting to certain 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!; 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 e3012add722b..5fe433246685 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Response/AccessRuleResponseModel.cs @@ -53,9 +53,9 @@ public AccessRuleResponseModel(AccessRuleDetails rule) public bool Enabled { get; } /// - /// 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. + /// The conditions that decide how access is granted under this rule — for example requiring human approval, + /// or restricting to certain 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; } diff --git a/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj b/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj index 0e79dabb563d..d7d3725365ec 100644 --- a/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj +++ b/bitwarden_license/test/Services/Pam.Test/Pam.Test.csproj @@ -1,6 +1,7 @@ + Bit.Services.Pam.Test false From cd15bee6aabd3198f192c110f94c2d8437967ff8 Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 27 Jul 2026 14:01:53 +0200 Subject: [PATCH 05/13] Extract the shared AccessRule write validation The create and update commands each carried their own copy of the same checks: name required, a positive maximum when extensions are allowed, the conditions document, name uniqueness within the organization, and the collection lookup that confirms every requested collection exists, belongs to the organization, and is not already governed by another rule. Move all of it into AccessRuleWriteValidator, which takes the id of the rule being updated, or null when creating. Both places where the two paths differ reduce to a comparison against that id: an update excludes itself from the uniqueness check and may keep the collections it already governs, and with a null id the same expressions give a create its stricter behaviour, where any governed collection conflicts. The validator returns the deduplicated collection ids so callers do not normalize them a second time. The commands keep only what is theirs. Neither needs ICollectionRepository any more, and update keeps its existence guard and the plain AccessRule it maps for persistence. Update now resolves the rule before judging the payload, so editing a rule that does not exist, or belongs to another organization, is a 404 rather than a 400 from a field check. The command tests assert the validator is never reached in those cases to hold that order in place, and the shared rules are covered once in AccessRuleWriteValidatorTests, including the create and update asymmetries. --- .../Commands/CreateAccessRuleCommand.cs | 64 +---- .../Commands/UpdateAccessRuleCommand.cs | 62 +---- .../Pam/Services/AccessRuleWriteValidator.cs | 92 +++++++ .../Pam/Services/IAccessRuleWriteValidator.cs | 23 ++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../Commands/CreateAccessRuleCommandTests.cs | 213 +++------------- .../Commands/UpdateAccessRuleCommandTests.cs | 141 +++------- .../Services/AccessRuleWriteValidatorTests.cs | 240 ++++++++++++++++++ 8 files changed, 436 insertions(+), 400 deletions(-) create mode 100644 bitwarden_license/src/Services/Pam/Services/AccessRuleWriteValidator.cs create mode 100644 bitwarden_license/src/Services/Pam/Services/IAccessRuleWriteValidator.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Services/AccessRuleWriteValidatorTests.cs diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index 966072e9831d..faf273bc3e0c 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,6 +1,4 @@ -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Pam.Entities; +using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; @@ -11,47 +9,22 @@ namespace Bit.Services.Pam.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { private readonly IAccessRuleRepository _repository; - private readonly ICollectionRepository _collectionRepository; - private readonly IAccessRuleValidator _validator; + private readonly IAccessRuleWriteValidator _validator; private readonly TimeProvider _timeProvider; public CreateAccessRuleCommand( IAccessRuleRepository repository, - ICollectionRepository collectionRepository, - IAccessRuleValidator validator, + IAccessRuleWriteValidator 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 desiredCollectionIds = await _validator.ValidateAsync(rule.OrganizationId, rule, collectionIds); var now = _timeProvider.GetUtcNow().UtcDateTime; rule.CreationDate = now; @@ -64,33 +37,4 @@ await _repository.SetCollectionAssociationsAsync( 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/UpdateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs index f1587d5847fc..eea90245d1de 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/UpdateAccessRuleCommand.cs @@ -1,5 +1,4 @@ using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Repositories; @@ -11,18 +10,15 @@ namespace Bit.Services.Pam.OrganizationFeatures.Commands; public class UpdateAccessRuleCommand : IUpdateAccessRuleCommand { private readonly IAccessRuleRepository _repository; - private readonly ICollectionRepository _collectionRepository; - private readonly IAccessRuleValidator _validator; + private readonly IAccessRuleWriteValidator _validator; private readonly TimeProvider _timeProvider; public UpdateAccessRuleCommand( IAccessRuleRepository repository, - ICollectionRepository collectionRepository, - IAccessRuleValidator validator, + IAccessRuleWriteValidator validator, TimeProvider timeProvider) { _repository = repository; - _collectionRepository = collectionRepository; _validator = validator; _timeProvider = timeProvider; } @@ -30,35 +26,13 @@ public UpdateAccessRuleCommand( 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); + var desiredCollectionIds = await _validator.ValidateAsync(organizationId, update, collectionIds, id); // Persist a plain AccessRule: the AccessRuleDetails returned by GetDetailsByIdAsync carries an extra // CollectionIds property that the base ReplaceAsync would otherwise forward to AccessRule_Update. @@ -87,34 +61,4 @@ public async Task UpdateAsync(Guid organizationId, Guid id, A 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/Services/AccessRuleWriteValidator.cs b/bitwarden_license/src/Services/Pam/Services/AccessRuleWriteValidator.cs new file mode 100644 index 000000000000..905afbc9b811 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Services/AccessRuleWriteValidator.cs @@ -0,0 +1,92 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; + +namespace Bit.Services.Pam.Services; + +/// +/// The shared validation for the AccessRule create and update paths. Create and update differ only in whether the +/// rule already exists, which both the name-uniqueness and collection-conflict checks express by comparing against +/// existingRuleId — null for a create, so nothing is excluded from either check. +/// +public class AccessRuleWriteValidator : IAccessRuleWriteValidator +{ + private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; + private readonly IAccessRuleValidator _conditionsValidator; + + public AccessRuleWriteValidator( + IAccessRuleRepository repository, + ICollectionRepository collectionRepository, + IAccessRuleValidator conditionsValidator) + { + _repository = repository; + _collectionRepository = collectionRepository; + _conditionsValidator = conditionsValidator; + } + + public async Task> ValidateAsync(Guid organizationId, AccessRule rule, + IEnumerable collectionIds, Guid? existingRuleId = null) + { + 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 conditions = _conditionsValidator.Validate(rule.Conditions); + if (!conditions.IsValid) + { + throw new BadRequestException(conditions.Error!); + } + + await ValidateNameIsUniqueAsync(organizationId, rule.Name, existingRuleId); + + return await ValidateCollectionsAsync(organizationId, collectionIds, existingRuleId); + } + + private async Task ValidateNameIsUniqueAsync(Guid organizationId, string name, Guid? existingRuleId) + { + var siblings = await _repository.GetManyByOrganizationIdAsync(organizationId); + if (siblings.Any(r => r.Id != existingRuleId && string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))) + { + throw new BadRequestException("A rule with that name already exists."); + } + } + + private async Task> ValidateCollectionsAsync(Guid organizationId, IEnumerable collectionIds, + Guid? existingRuleId) + { + 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. A rule being created has no id, so for it + // any link at all conflicts. + if (collections.Any(c => c.AccessRuleId.HasValue && c.AccessRuleId != existingRuleId)) + { + throw new BadRequestException("One or more collections are already governed by another access rule."); + } + + return distinctIds; + } +} diff --git a/bitwarden_license/src/Services/Pam/Services/IAccessRuleWriteValidator.cs b/bitwarden_license/src/Services/Pam/Services/IAccessRuleWriteValidator.cs new file mode 100644 index 000000000000..1823c36ba664 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Services/IAccessRuleWriteValidator.cs @@ -0,0 +1,23 @@ +using Bit.Core.Exceptions; +using Bit.Pam.Entities; + +namespace Bit.Services.Pam.Services; + +public interface IAccessRuleWriteValidator +{ + /// + /// Validates a rule that is about to be persisted — its own fields, its conditions document, its name's + /// uniqueness within the organization, and the collections it is to govern — and returns the deduplicated + /// collection ids to associate with it. + /// + /// The organization the rule belongs to, and the only one its collections may + /// belong to. + /// The rule as it will be persisted. + /// The complete set of collections the rule should govern. + /// The id of the rule being updated, or null when creating. An update is excluded + /// from its own name-uniqueness check and may keep the collections it already governs, whereas a create + /// conflicts with any already-governed collection. + /// Thrown on the first validation failure. + Task> ValidateAsync(Guid organizationId, AccessRule rule, IEnumerable collectionIds, + Guid? existingRuleId = null); +} diff --git a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs index 74404e98e7a9..3e8d75435ad6 100644 --- a/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Services/Pam/Utilities/ServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ public static IServiceCollection AddPamServices(this IServiceCollection services // AccessRule write path. services.TryAddSingleton(TimeProvider.System); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs index 6d15d453a279..6f74dc5f0a28 100644 --- a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs @@ -1,6 +1,5 @@ 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; @@ -9,10 +8,15 @@ using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; namespace Bit.Services.Pam.Test.Commands; +/// +/// The validation these commands share lives in and is covered by +/// AccessRuleWriteValidatorTests; these tests cover persistence, timestamps, and collection association wiring. +/// [SutProviderCustomize] public class CreateAccessRuleCommandTests { @@ -23,15 +27,10 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces { var sutProvider = SetupSutProvider(); rule.Name = "VPN + business hours"; - rule.Conditions = """{"kind":"human_approval"}"""; + 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()); + SetupValidator(sutProvider, rule.OrganizationId, []); sutProvider.GetDependency() .CreateAsync(rule) .Returns(rule); @@ -45,6 +44,9 @@ public async Task CreateAsync_HappyPath_PersistsWithTimestampsAndValidates(Acces await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Is(r => r.DefaultLeaseDurationSeconds == 3600 && r.MaxLeaseDurationSeconds == 28800)); + // A create has no existing rule to exclude from the validator's uniqueness and conflict checks. + await sutProvider.GetDependency().Received(1) + .ValidateAsync(rule.OrganizationId, rule, Arg.Any>(), null); } [Theory, BitAutoData] @@ -53,193 +55,31 @@ public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRul { 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; + rule.Conditions = """[{"kind":"human_approval"}]"""; var collectionIds = new[] { collectionA.Id, collectionB.Id }; - sutProvider.GetDependency() - .Validate(rule.Conditions) - .Returns(AccessRuleValidationResult.Valid); - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(rule.OrganizationId) - .Returns(new List()); + SetupValidator(sutProvider, rule.OrganizationId, [.. collectionIds]); 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); + var result = await sutProvider.Sut.CreateAsync(rule, collectionIds); + Assert.Equal(collectionIds, result.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.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()); + SetupValidator(sutProvider, rule.OrganizationId, []); sutProvider.GetDependency() .CreateAsync(rule) .Returns(rule); @@ -252,6 +92,27 @@ await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Is(r => r.AllowsExtensions && r.MaxExtensionDurationSeconds == 3600)); } + [Theory, BitAutoData] + public async Task CreateAsync_ValidationFails_DoesNotPersist(AccessRule rule) + { + var sutProvider = SetupSutProvider(); + sutProvider.GetDependency() + .ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .ThrowsAsync(new BadRequestException("Name is required.")); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); + Assert.Equal("Name is required.", ex.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SetCollectionAssociationsAsync(default, default, default!, default!); + } + + private static void SetupValidator(SutProvider sutProvider, Guid organizationId, + List validatedCollectionIds) + => sutProvider.GetDependency() + .ValidateAsync(organizationId, Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(validatedCollectionIds); + private static SutProvider SetupSutProvider() { var sutProvider = new SutProvider() diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs index 656b5ef5c21e..2f0a79f2279e 100644 --- a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs @@ -1,6 +1,4 @@ -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; +using Bit.Core.Exceptions; using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Repositories; @@ -10,10 +8,15 @@ using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Time.Testing; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; namespace Bit.Services.Pam.Test.Commands; +/// +/// The validation these commands share lives in and is covered by +/// AccessRuleWriteValidatorTests; these tests cover persistence, timestamps, and collection association wiring. +/// [SutProviderCustomize] public class UpdateAccessRuleCommandTests { @@ -27,7 +30,7 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule existing.CollectionIds = []; update.Name = "renamed"; update.Description = "new description"; - update.Conditions = """{"kind":"human_approval"}"""; + update.Conditions = """[{"kind":"human_approval"}]"""; update.SingleActiveLease = true; update.DefaultLeaseDurationSeconds = 3600; update.MaxLeaseDurationSeconds = 28800; @@ -36,12 +39,7 @@ public async Task UpdateAsync_HappyPath_UpdatesFieldsAndBumpsRevision(AccessRule sutProvider.GetDependency() .GetDetailsByIdAsync(existing.Id) .Returns(existing); - sutProvider.GetDependency() - .Validate(update.Conditions) - .Returns(AccessRuleValidationResult.Valid); - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(orgId) - .Returns(new List { existing }); + SetupValidator(sutProvider, orgId, existing.Id, []); var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); @@ -60,65 +58,25 @@ await sutProvider.GetDependency().Received(1) && 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!); + // The rule under update is excluded from the validator's uniqueness and conflict checks by its own id. + await sutProvider.GetDependency().Received(1) + .ValidateAsync(orgId, update, Arg.Any>(), existing.Id); } [Theory, BitAutoData] public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(AccessRuleDetails existing, - AccessRule update, Collection keep, Collection add) + AccessRule update, Guid keptId, Guid addedId) { 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 desired = new[] { keptId, addedId }; var removedId = Guid.NewGuid(); - existing.CollectionIds = [keep.Id, removedId]; + existing.CollectionIds = [keptId, 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 }); + SetupValidator(sutProvider, orgId, existing.Id, [.. desired]); var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, desired); @@ -135,18 +93,12 @@ public async Task UpdateAsync_EmptyCollections_ClearsAll(AccessRuleDetails exist 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 }); + SetupValidator(sutProvider, orgId, existing.Id, []); var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); @@ -158,38 +110,7 @@ await sutProvider.GetDependency().Received(1) } [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) + public async Task UpdateAsync_MissingExisting_ThrowsNotFoundWithoutValidating(AccessRule update) { var sutProvider = SetupSutProvider(); sutProvider.GetDependency() @@ -198,6 +119,9 @@ public async Task UpdateAsync_MissingExisting_ThrowsNotFound(AccessRule update) await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(Guid.NewGuid(), Guid.NewGuid(), update, [])); + // A rule the caller cannot see is a 404 before anything about the payload is judged. + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ValidateAsync(default, default!, default!, default); } [Theory, BitAutoData] @@ -211,28 +135,35 @@ public async Task UpdateAsync_WrongOrg_ThrowsNotFound(AccessRuleDetails existing await Assert.ThrowsAsync( () => sutProvider.Sut.UpdateAsync(differentOrg, existing.Id, update, [])); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .ValidateAsync(default, default!, default!, default); } [Theory, BitAutoData] - public async Task UpdateAsync_InvalidRule_ThrowsBadRequest(AccessRuleDetails existing, AccessRule update) + public async Task UpdateAsync_ValidationFails_DoesNotPersist(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")); + sutProvider.GetDependency() + .ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .ThrowsAsync(new BadRequestException("A rule with that name already exists.")); var ex = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, [])); - Assert.Equal("nope", ex.Message); + () => sutProvider.Sut.UpdateAsync(existing.OrganizationId, existing.Id, update, [])); + Assert.Equal("A rule with that name already exists.", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SetCollectionAssociationsAsync(default, default, default!, default!); } + private static void SetupValidator(SutProvider sutProvider, Guid organizationId, + Guid existingRuleId, List validatedCollectionIds) + => sutProvider.GetDependency() + .ValidateAsync(organizationId, Arg.Any(), Arg.Any>(), existingRuleId) + .Returns(validatedCollectionIds); + private static SutProvider SetupSutProvider() { var sutProvider = new SutProvider() diff --git a/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleWriteValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleWriteValidatorTests.cs new file mode 100644 index 000000000000..650b4e79ca3e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Services/AccessRuleWriteValidatorTests.cs @@ -0,0 +1,240 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +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 AccessRuleWriteValidatorTests +{ + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task ValidateAsync_EmptyName_ThrowsBadRequest(string name, AccessRule rule) + { + var sutProvider = new SutProvider().Create(); + rule.Name = name; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [])); + Assert.Contains("Name is required", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_AllowsExtensionsWithoutMax_ThrowsBadRequest(AccessRule rule) + { + var sutProvider = new SutProvider().Create(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensionDurationSeconds = null; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [])); + Assert.Contains("maximum extension length", ex.Message); + } + + [Theory] + [BitAutoData(0)] + [BitAutoData(-1)] + public async Task ValidateAsync_AllowsExtensionsWithNonPositiveMax_ThrowsBadRequest( + int maxExtensionDurationSeconds, AccessRule rule) + { + var sutProvider = new SutProvider().Create(); + rule.Name = "extendable"; + rule.AllowsExtensions = true; + rule.MaxExtensionDurationSeconds = maxExtensionDurationSeconds; + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [])); + Assert.Contains("maximum extension length", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_InvalidConditions_ThrowsBadRequestWithValidatorError(AccessRule rule) + { + var sutProvider = SetupSutProvider(rule); + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Invalid("Unsupported condition kind")); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [])); + Assert.Equal("Unsupported condition kind", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DuplicateName_ThrowsBadRequest(AccessRule rule, AccessRule sibling) + { + var sutProvider = SetupSutProvider(rule); + rule.Name = "duplicate"; + sibling.OrganizationId = rule.OrganizationId; + sibling.Name = "Duplicate"; // case-insensitive collision + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { sibling }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [])); + Assert.Contains("already exists", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UpdateKeepingItsOwnName_IsValid(AccessRule rule) + { + var sutProvider = SetupSutProvider(rule); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { rule }); + + var result = await sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [], rule.Id); + + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UpdateTakingAnotherRulesName_ThrowsBadRequest(AccessRule rule, AccessRule sibling) + { + var sutProvider = SetupSutProvider(rule); + rule.Name = "taken"; + sibling.OrganizationId = rule.OrganizationId; + sibling.Name = "taken"; + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List { rule, sibling }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [], rule.Id)); + Assert.Contains("already exists", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_NoCollections_SkipsCollectionLookup(AccessRule rule) + { + var sutProvider = SetupSutProvider(rule); + + var result = await sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, []); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyByManyIdsAsync(default!); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DuplicateCollectionIds_ReturnsThemDeduplicated(AccessRule rule, + Collection collection) + { + var sutProvider = SetupSutProvider(rule); + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = null; + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var result = await sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, + [collection.Id, collection.Id]); + + Assert.Equal([collection.Id], result); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_CollectionNotFound_ThrowsBadRequest(AccessRule rule, Guid missingCollectionId) + { + var sutProvider = SetupSutProvider(rule); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List()); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [missingCollectionId])); + Assert.Contains("could not be found", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_CollectionInDifferentOrg_ThrowsBadRequest(AccessRule rule, Collection collection) + { + var sutProvider = SetupSutProvider(rule); + collection.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [collection.Id])); + Assert.Contains("do not belong to this organization", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_CreateWithGovernedCollection_ThrowsBadRequest(AccessRule rule, + AccessRule otherRule, Collection collection) + { + var sutProvider = SetupSutProvider(rule); + otherRule.OrganizationId = rule.OrganizationId; + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = otherRule.Id; + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [collection.Id])); + Assert.Contains("already governed by another access rule", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UpdateWithCollectionGovernedByAnotherRule_ThrowsBadRequest(AccessRule rule, + AccessRule otherRule, Collection collection) + { + var sutProvider = SetupSutProvider(rule); + otherRule.OrganizationId = rule.OrganizationId; + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = otherRule.Id; + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [collection.Id], rule.Id)); + Assert.Contains("already governed by another access rule", ex.Message); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UpdateWithCollectionItAlreadyGoverns_IsValid(AccessRule rule, + Collection collection) + { + var sutProvider = SetupSutProvider(rule); + collection.OrganizationId = rule.OrganizationId; + collection.AccessRuleId = rule.Id; // already governed by the rule under update + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Any>()) + .Returns(new List { collection }); + + var result = await sutProvider.Sut.ValidateAsync(rule.OrganizationId, rule, [collection.Id], rule.Id); + + Assert.Equal([collection.Id], result); + } + + /// + /// Sets up a rule that passes the field-level checks, with the conditions validator and the sibling lookup + /// stubbed to succeed, so each test only has to arrange the check it is exercising. + /// + private static SutProvider SetupSutProvider(AccessRule rule) + { + var sutProvider = new SutProvider().Create(); + rule.Name = "rule"; + rule.Conditions = """[{"kind":"human_approval"}]"""; + sutProvider.GetDependency() + .Validate(rule.Conditions) + .Returns(AccessRuleValidationResult.Valid); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(rule.OrganizationId) + .Returns(new List()); + return sutProvider; + } +} From 4ce36879d02fa9fe0262784ddd1d0f9ea0a523f7 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 30 Jul 2026 13:33:15 +0200 Subject: [PATCH 06/13] Regenerate packages.lock.json for the new Pam.Domain reference in Pam.csproj Pam.csproj now references Pam.Domain directly for the AccessRule command and validator implementations, so consumers of the Pam service need the reference recorded in their own lock files too. Force-evaluated a full-solution restore to bring every lock file back in sync. --- .../src/Services/Pam/packages.lock.json | 6 + .../test/Services/Pam.Test/packages.lock.json | 140 +++++++++++++++++- src/Api/packages.lock.json | 3 +- test/Api.IntegrationTest/packages.lock.json | 3 +- test/Api.Test/packages.lock.json | 3 +- .../packages.lock.json | 3 +- util/SqlServerEFScaffold/packages.lock.json | 3 +- 7 files changed, 155 insertions(+), 6 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/packages.lock.json b/bitwarden_license/src/Services/Pam/packages.lock.json index 9b6ba7c35660..5e462472e4f9 100644 --- a/bitwarden_license/src/Services/Pam/packages.lock.json +++ b/bitwarden_license/src/Services/Pam/packages.lock.json @@ -916,6 +916,12 @@ "Core": "[2026.8.0, )" } }, + "pam.domain": { + "type": "Project", + "dependencies": { + "Data": "[0.0.1, )" + } + }, "serilogfilelogging": { "type": "Project", "dependencies": { diff --git a/bitwarden_license/test/Services/Pam.Test/packages.lock.json b/bitwarden_license/test/Services/Pam.Test/packages.lock.json index 2b0f966ca7d8..6dd6dc7c509a 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", @@ -221,6 +247,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", @@ -262,6 +296,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", @@ -293,6 +335,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", @@ -614,6 +665,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", @@ -715,6 +775,16 @@ "Microsoft.Extensions.Options": "10.0.9" } }, + "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.9", @@ -802,6 +872,11 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "aQBFbY8i/dacE0fP+ZJ8Lhx/unYRnGHhtM+tHb46GLkeNjBdOzgFk88sX6BVZBhoa6JrYIOBGYTc5K4WItBsag==" + }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "10.0.9", @@ -828,6 +903,22 @@ "resolved": "10.0.9", "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, + "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", @@ -964,6 +1055,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", @@ -1014,6 +1113,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", @@ -1254,6 +1358,19 @@ "ZiggyCreatures.FusionCache": "2.0.2" } }, + "common": { + "type": "Project", + "dependencies": { + "AutoFixture.AutoNSubstitute": "[4.18.1, )", + "AutoFixture.Xunit2": "[4.18.1, )", + "Core": "[2026.8.0, )", + "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": { "type": "Project", "dependencies": { @@ -1311,6 +1428,20 @@ "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.8.0, )", + "Core": "[2026.8.0, )", + "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" }, @@ -1334,7 +1465,14 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" + } + }, + "pam.domain": { + "type": "Project", + "dependencies": { + "Data": "[0.0.1, )" } }, "serilogfilelogging": { diff --git a/src/Api/packages.lock.json b/src/Api/packages.lock.json index 5e8f3d406ce2..965fade6ff7f 100644 --- a/src/Api/packages.lock.json +++ b/src/Api/packages.lock.json @@ -1279,7 +1279,8 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" } }, "pam.domain": { diff --git a/test/Api.IntegrationTest/packages.lock.json b/test/Api.IntegrationTest/packages.lock.json index fdc94ab8fd12..049ca87f7250 100644 --- a/test/Api.IntegrationTest/packages.lock.json +++ b/test/Api.IntegrationTest/packages.lock.json @@ -1557,7 +1557,8 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" } }, "pam.domain": { diff --git a/test/Api.Test/packages.lock.json b/test/Api.Test/packages.lock.json index 9b60931b94cd..9b73ac0b431f 100644 --- a/test/Api.Test/packages.lock.json +++ b/test/Api.Test/packages.lock.json @@ -1895,7 +1895,8 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" } }, "pam.domain": { diff --git a/test/Billing.IntegrationTest/packages.lock.json b/test/Billing.IntegrationTest/packages.lock.json index b6343e52d589..d1bbb9ccd48b 100644 --- a/test/Billing.IntegrationTest/packages.lock.json +++ b/test/Billing.IntegrationTest/packages.lock.json @@ -2102,7 +2102,8 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" } }, "pam.domain": { diff --git a/util/SqlServerEFScaffold/packages.lock.json b/util/SqlServerEFScaffold/packages.lock.json index 9f990b997173..35f980fae7be 100644 --- a/util/SqlServerEFScaffold/packages.lock.json +++ b/util/SqlServerEFScaffold/packages.lock.json @@ -1792,7 +1792,8 @@ "dependencies": { "Core": "[2026.8.0, )", "HttpExtensions": "[2026.8.0, )", - "OrganizationAuthorization": "[0.0.1, )" + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" } }, "pam.domain": { From 1259f99f088dbdaa613fa6287604180668fbbce3 Mon Sep 17 00:00:00 2001 From: Hinton Date: Fri, 31 Jul 2026 17:00:18 +0200 Subject: [PATCH 07/13] Follow the SetAccessRuleAssociations move onto ICollectionRepository The access-rule commands now take ICollectionRepository for the collection association write, matching the method's new home after review feedback on #7981. --- .../Commands/CreateAccessRuleCommand.cs | 8 ++++++-- .../Commands/UpdateAccessRuleCommand.cs | 6 +++++- .../Commands/CreateAccessRuleCommandTests.cs | 9 +++++---- .../Commands/UpdateAccessRuleCommandTests.cs | 13 +++++++------ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs index faf273bc3e0c..4263db72bece 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/CreateAccessRuleCommand.cs @@ -1,4 +1,5 @@ -using Bit.Pam.Entities; +using Bit.Core.Repositories; +using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Repositories; using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; @@ -9,15 +10,18 @@ namespace Bit.Services.Pam.OrganizationFeatures.Commands; public class CreateAccessRuleCommand : ICreateAccessRuleCommand { private readonly IAccessRuleRepository _repository; + private readonly ICollectionRepository _collectionRepository; private readonly IAccessRuleWriteValidator _validator; private readonly TimeProvider _timeProvider; public CreateAccessRuleCommand( IAccessRuleRepository repository, + ICollectionRepository collectionRepository, IAccessRuleWriteValidator validator, TimeProvider timeProvider) { _repository = repository; + _collectionRepository = collectionRepository; _validator = validator; _timeProvider = timeProvider; } @@ -32,7 +36,7 @@ public async Task CreateAsync(AccessRule rule, IEnumerable UpdateAsync(Guid organizationId, Guid id, A await _repository.ReplaceAsync(toPersist); var toClear = existing.CollectionIds.Except(desiredCollectionIds).ToList(); - await _repository.SetCollectionAssociationsAsync(organizationId, id, desiredCollectionIds, toClear); + await _collectionRepository.SetAccessRuleAssociationsAsync(organizationId, id, desiredCollectionIds, toClear); return AccessRuleDetails.From(toPersist, desiredCollectionIds); } diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs index 6f74dc5f0a28..0a169019db61 100644 --- a/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Commands/CreateAccessRuleCommandTests.cs @@ -1,5 +1,6 @@ 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; @@ -65,8 +66,8 @@ public async Task CreateAsync_WithCollections_AssociatesAndReturnsThem(AccessRul var result = await sutProvider.Sut.CreateAsync(rule, collectionIds); Assert.Equal(collectionIds, result.CollectionIds); - await sutProvider.GetDependency().Received(1) - .SetCollectionAssociationsAsync(rule.OrganizationId, rule.Id, + await sutProvider.GetDependency().Received(1) + .SetAccessRuleAssociationsAsync(rule.OrganizationId, rule.Id, Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(collectionIds.OrderBy(x => x))), Arg.Is>(ids => !ids.Any())); } @@ -103,8 +104,8 @@ public async Task CreateAsync_ValidationFails_DoesNotPersist(AccessRule rule) var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(rule, [])); Assert.Equal("Name is required.", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default!); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .SetCollectionAssociationsAsync(default, default, default!, default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SetAccessRuleAssociationsAsync(default, default, default!, default!); } private static void SetupValidator(SutProvider sutProvider, Guid organizationId, diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs index 2f0a79f2279e..769d2cc4138d 100644 --- a/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Commands/UpdateAccessRuleCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Pam.Entities; using Bit.Pam.Models; using Bit.Pam.Repositories; @@ -81,8 +82,8 @@ public async Task UpdateAsync_ReplacesCollections_AssignsNewAndClearsRemoved(Acc 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, + await sutProvider.GetDependency().Received(1) + .SetAccessRuleAssociationsAsync(orgId, existing.Id, Arg.Is>(ids => ids.OrderBy(x => x).SequenceEqual(desired.OrderBy(x => x))), Arg.Is>(ids => ids.SequenceEqual(new[] { removedId }))); } @@ -103,8 +104,8 @@ public async Task UpdateAsync_EmptyCollections_ClearsAll(AccessRuleDetails exist var result = await sutProvider.Sut.UpdateAsync(orgId, existing.Id, update, []); Assert.Empty(result.CollectionIds); - await sutProvider.GetDependency().Received(1) - .SetCollectionAssociationsAsync(orgId, existing.Id, + await sutProvider.GetDependency().Received(1) + .SetAccessRuleAssociationsAsync(orgId, existing.Id, Arg.Is>(ids => !ids.Any()), Arg.Is>(ids => ids.SequenceEqual(new[] { currentId }))); } @@ -154,8 +155,8 @@ public async Task UpdateAsync_ValidationFails_DoesNotPersist(AccessRuleDetails e () => sutProvider.Sut.UpdateAsync(existing.OrganizationId, existing.Id, update, [])); Assert.Equal("A rule with that name already exists.", ex.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .SetCollectionAssociationsAsync(default, default, default!, default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SetAccessRuleAssociationsAsync(default, default, default!, default!); } private static void SetupValidator(SutProvider sutProvider, Guid organizationId, From 3615d7ad2a3982d8e8a4eccd2dc6958ddf94499b Mon Sep 17 00:00:00 2001 From: Hinton Date: Mon, 3 Aug 2026 10:41:17 +0200 Subject: [PATCH 08/13] Stop the access-rule handler from re-checking authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoints already authorize through the standard authorization middleware: the group requires organization membership and the writes additionally require ManageAccessRulesRequirement (see AccessRuleEndpoints). The handler's ICurrentContext membership and admin checks are a second, weaker copy of a decision that has already been made — and they hand back 404 where the middleware returns 403. The rule.OrganizationId != orgId check stays. That one is resource scoping rather than authorization: it stops a rule ID from one organization being read through another organization's route. --- .../Handlers/AccessRuleEndpointsHandler.cs | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) 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 8fe214f52ec3..053d459a00cf 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs @@ -12,6 +12,11 @@ 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. /// +/// +/// Access to the organization is already settled by the time a handler runs — AccessRuleEndpoints authorizes +/// the group and the write endpoints through the standard authorization middleware. What is left here is resource +/// scoping: confirming a rule reached by ID actually belongs to the organization on the route. +/// public class AccessRuleEndpointsHandler( ICurrentContext currentContext, IAccessRuleRepository repository, @@ -21,8 +26,6 @@ public class AccessRuleEndpointsHandler( { public async Task> GetAll(Guid orgId) { - await EnsureMemberAsync(orgId); - var rules = await repository.GetManyDetailsByOrganizationIdAsync(orgId); return new ListResponseModel( rules.Select(rule => new AccessRuleResponseModel(rule))); @@ -30,8 +33,6 @@ public async Task> GetAll(Guid orgId) public async Task Get(Guid orgId, Guid id) { - await EnsureMemberAsync(orgId); - var rule = await repository.GetDetailsByIdAsync(id); if (rule is null || rule.OrganizationId != orgId) { @@ -43,8 +44,6 @@ public async Task Get(Guid orgId, Guid id) 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); @@ -53,8 +52,6 @@ public async Task Post(Guid orgId, AccessRuleRequestMod 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); @@ -63,24 +60,6 @@ public async Task Put(Guid orgId, Guid id, AccessRuleRe 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(); - } - } } From a00e7af0a29b8289233eea7390ba3ca92c86589e Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 11 Aug 2026 11:57:19 +0200 Subject: [PATCH 09/13] Give PAM its own integration test project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access-rule endpoints ship under bitwarden_license, so the tests that drive them over the real request pipeline belong there rather than in the OSS Api.IntegrationTest project. That project is referenced for its host fixture and its organization/login helpers — the same dependency Billing.IntegrationTest takes on — rather than reimplemented here. AccessRuleAuthorizationTests moves across unchanged apart from the namespace and the setup it now takes from AccessRuleIntegrationTestBase, which the endpoint tests landing next to it share: an Api host over SQLite, the PAM feature flag on, and an enterprise organization whose owner can be logged in. The appsettings item in the csproj is load-bearing rather than boilerplate. WebApplicationFactoryBase reads appsettings.json and appsettings.Development.json from the test assembly's own directory, and several referenced projects ship a file under each name. Without naming the Api's copies explicitly another project's win the copy, the host starts without globalSettings:pricingUri, and every test fails in organization signup with an unhelpful "BaseAddress must be set" from the pricing client. --- bitwarden-server.slnx | 1 + .../AccessRuleAuthorizationTests.cs | 117 +- .../AccessRuleIntegrationTestBase.cs | 75 + .../Pam.IntegrationTest.csproj | 37 + .../Pam.IntegrationTest/packages.lock.json | 2096 +++++++++++++++++ 5 files changed, 2248 insertions(+), 78 deletions(-) rename {test/Api.IntegrationTest/Pam => bitwarden_license/test/Services/Pam.IntegrationTest}/AccessRuleAuthorizationTests.cs (51%) create mode 100644 bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleIntegrationTestBase.cs create mode 100644 bitwarden_license/test/Services/Pam.IntegrationTest/Pam.IntegrationTest.csproj create mode 100644 bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx index 04f7c6461766..c5003fbca99a 100644 --- a/bitwarden-server.slnx +++ b/bitwarden-server.slnx @@ -51,6 +51,7 @@ + diff --git a/test/Api.IntegrationTest/Pam/AccessRuleAuthorizationTests.cs b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs similarity index 51% rename from test/Api.IntegrationTest/Pam/AccessRuleAuthorizationTests.cs rename to bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs index dc60c82b938b..aacf96ee18b7 100644 --- a/test/Api.IntegrationTest/Pam/AccessRuleAuthorizationTests.cs +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs @@ -1,8 +1,7 @@ -using System.Net; +using System.Net; +using System.Net.Http.Json; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; @@ -11,64 +10,30 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bitwarden.Server.Sdk.Features; -using NSubstitute; using Xunit; -namespace Bit.Api.IntegrationTest.Pam; +namespace Bit.Services.Pam.IntegrationTest; /// /// Authorization for organizations/{orgId}/access-rules, exercised over the real request pipeline. /// /// /// The endpoint-registration tests in Pam.Test assert which requirements are attached to which route, but they stop -/// before the pipeline runs — presence in metadata is not enforcement. These tests assert only denials, which stay -/// valid once the handler scaffolds are implemented; the allowed cases assert merely that the caller got past -/// authorization, since the handlers currently throw. +/// before the pipeline runs — presence in metadata is not enforcement. These tests deliberately assert only that a +/// caller was or was not denied, never what the handler returned; the round-trip behaviour is +/// 's subject. /// -public class AccessRuleAuthorizationTests : IClassFixture, IAsyncLifetime +public class AccessRuleAuthorizationTests(ApiApplicationFactory factory) + : AccessRuleIntegrationTestBase(factory, "pam-access-rule-authz") { - private readonly HttpClient _client; - private readonly ApiApplicationFactory _factory; - private readonly LoginHelper _loginHelper; - private readonly IFeatureService _featureService; - - private Organization _organization = null!; - private string _ownerEmail = null!; - - public AccessRuleAuthorizationTests(ApiApplicationFactory factory) - { - _factory = factory; - _factory.SubstituteService(_ => { }); - _client = _factory.CreateClient(); - _loginHelper = new LoginHelper(_factory, _client); - _featureService = _factory.GetService(); - } - - public async Task InitializeAsync() - { - _featureService.IsEnabled(FeatureFlagKeys.Pam).Returns(true); - - _ownerEmail = $"pam-access-rule-authz-{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(_ownerEmail); - (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, - ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); - } - - public Task DisposeAsync() - { - _client.Dispose(); - return Task.CompletedTask; - } - [Fact] public async Task Read_AsNonMember_ReturnsForbidden() { var outsiderEmail = $"outsider-{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(outsiderEmail); - await _loginHelper.LoginAsync(outsiderEmail); + await Factory.LoginWithNewAccount(outsiderEmail); + await LoginHelper.LoginAsync(outsiderEmail); - var response = await _client.GetAsync(AccessRules()); + var response = await Client.GetAsync(AccessRulesUrl); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -80,8 +45,8 @@ public async Task Read_AsNonMember_ReturnsForbidden() public async Task Write_AsNonMember_ReturnsForbidden(string method) { var outsiderEmail = $"outsider-{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(outsiderEmail); - await _loginHelper.LoginAsync(outsiderEmail); + await Factory.LoginWithNewAccount(outsiderEmail); + await LoginHelper.LoginAsync(outsiderEmail); var response = await SendWriteAsync(method); @@ -94,9 +59,9 @@ public async Task Write_AsNonMember_ReturnsForbidden(string method) [InlineData("DELETE")] public async Task Write_AsPlainMember_ReturnsForbidden(string method) { - var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, - _organization.Id, OrganizationUserType.User); - await _loginHelper.LoginAsync(memberEmail); + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.User); + await LoginHelper.LoginAsync(memberEmail); var response = await SendWriteAsync(method); @@ -109,9 +74,9 @@ public async Task Write_AsPlainMember_ReturnsForbidden(string method) [InlineData("DELETE")] public async Task Write_AsCustomUserWithoutManageAccessRules_ReturnsForbidden(string method) { - var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, - _organization.Id, OrganizationUserType.Custom, new Permissions { ManageAccessRules = false }); - await _loginHelper.LoginAsync(customEmail); + var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.Custom, new Permissions { ManageAccessRules = false }); + await LoginHelper.LoginAsync(customEmail); var response = await SendWriteAsync(method); @@ -125,7 +90,7 @@ public async Task Read_AsProviderUserForTheOrganization_ReturnsForbidden() // credentials out of it. The group deliberately uses MemberRequirement, not MemberOrProviderRequirement. await LoginAsProviderForOrganizationAsync(); - var response = await _client.GetAsync(AccessRules()); + var response = await Client.GetAsync(AccessRulesUrl); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } @@ -143,13 +108,12 @@ public async Task Write_AsProviderUserForTheOrganization_ReturnsForbidden() [Fact] public async Task Read_AsMember_IsNotForbidden() { - // Guards against the group requirement over-denying. The handler is a scaffold that throws, so this - // asserts only that authorization let the caller through. - var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, - _organization.Id, OrganizationUserType.User); - await _loginHelper.LoginAsync(memberEmail); + // Guards against the group requirement over-denying: reading rules is available to any member. + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(Factory, + Organization.Id, OrganizationUserType.User); + await LoginHelper.LoginAsync(memberEmail); - var response = await _client.GetAsync(AccessRules()); + var response = await Client.GetAsync(AccessRulesUrl); AssertReachedTheHandler(response); } @@ -157,7 +121,7 @@ public async Task Read_AsMember_IsNotForbidden() [Fact] public async Task Write_AsOwner_IsNotForbidden() { - await _loginHelper.LoginAsync(_ownerEmail); + await LoginHelper.LoginAsync(OwnerEmail); var response = await SendWriteAsync("POST"); @@ -165,10 +129,9 @@ public async Task Write_AsOwner_IsNotForbidden() } /// - /// Asserts a caller got past authorization without pinning what the scaffold handler does. NotFound is excluded - /// as well as Forbidden: without it these would still pass if the PAM feature gate silently swallowed the route, - /// which would in turn make every denial above pass for the wrong reason. Neither status is a legitimate result - /// for these two requests once the handlers are implemented. + /// Asserts a caller got past authorization without pinning what the handler did with a deliberately empty body. + /// NotFound is excluded as well as Forbidden: without it these would still pass if the PAM feature gate silently + /// swallowed the route, which would in turn make every denial above pass for the wrong reason. /// private static void AssertReachedTheHandler(HttpResponseMessage response) { @@ -176,40 +139,38 @@ private static void AssertReachedTheHandler(HttpResponseMessage response) Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); } - private string AccessRules() => $"organizations/{_organization.Id}/access-rules"; - private Task SendWriteAsync(string method) => method switch { - "POST" => _client.PostAsJsonAsync(AccessRules(), new { }), - "PUT" => _client.PutAsJsonAsync($"{AccessRules()}/{Guid.NewGuid()}", new { }), - "DELETE" => _client.DeleteAsync($"{AccessRules()}/{Guid.NewGuid()}"), + "POST" => Client.PostAsJsonAsync(AccessRulesUrl, new { }), + "PUT" => Client.PutAsJsonAsync(AccessRuleUrl(Guid.NewGuid()), new { }), + "DELETE" => Client.DeleteAsync(AccessRuleUrl(Guid.NewGuid())), _ => throw new ArgumentOutOfRangeException(nameof(method)) }; private async Task LoginAsProviderForOrganizationAsync() { var providerEmail = $"provider-{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(providerEmail); + await Factory.LoginWithNewAccount(providerEmail); - await _factory.GetService() + await Factory.GetService() .CreateBusinessUnitAsync( new Provider { Name = "provider", Type = ProviderType.BusinessUnit }, providerEmail, PlanType.EnterpriseAnnually2023, 10); - var providerUserAccount = await _factory.GetService().GetByEmailAsync(providerEmail); - var providerUser = (await _factory.GetService() + var providerUserAccount = await Factory.GetService().GetByEmailAsync(providerEmail); + var providerUser = (await Factory.GetService() .GetManyByUserAsync(providerUserAccount!.Id)).First(); - await _factory.GetService().CreateAsync(new ProviderOrganization + await Factory.GetService().CreateAsync(new ProviderOrganization { ProviderId = providerUser.ProviderId, - OrganizationId = _organization.Id, + OrganizationId = Organization.Id, Key = null, Settings = null }); - await _loginHelper.LoginAsync(providerEmail); + await LoginHelper.LoginAsync(providerEmail); } } diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleIntegrationTestBase.cs b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleIntegrationTestBase.cs new file mode 100644 index 000000000000..427472f77b0f --- /dev/null +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleIntegrationTestBase.cs @@ -0,0 +1,75 @@ +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bitwarden.Server.Sdk.Features; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.IntegrationTest; + +/// +/// Shared harness for the organizations/{orgId}/access-rules integration tests: the Api host over SQLite, the +/// PAM feature flag on, and an enterprise organization whose owner can be logged in. +/// +/// +/// PAM ships under bitwarden_license, so its integration tests live here. Api.IntegrationTest is referenced +/// only for the host fixture and the organization/login helpers, the same way Billing.IntegrationTest consumes them. +/// +/// Each test class gets its own — and so its own database — because xUnit scopes +/// per class. Setup runs per test, since xUnit constructs a fresh test-class instance +/// for every test, which is what keeps a test that flips the feature flag from leaking into its siblings. +/// +/// +public abstract class AccessRuleIntegrationTestBase : IClassFixture, IAsyncLifetime +{ + private readonly string _emailPrefix; + + protected AccessRuleIntegrationTestBase(ApiApplicationFactory factory, string emailPrefix) + { + Factory = factory; + // Every PAM group sits behind RequireFeature(FeatureFlagKeys.Pam), so without a substituted feature service + // the whole surface is unroutable and every assertion below would pass for the wrong reason. + Factory.SubstituteService(_ => { }); + Client = factory.CreateClient(); + LoginHelper = new LoginHelper(factory, Client); + FeatureService = factory.GetService(); + _emailPrefix = emailPrefix; + } + + protected ApiApplicationFactory Factory { get; } + + protected HttpClient Client { get; } + + protected LoginHelper LoginHelper { get; } + + protected IFeatureService FeatureService { get; } + + protected Organization Organization { get; private set; } = null!; + + protected string OwnerEmail { get; private set; } = null!; + + protected string AccessRulesUrl => AccessRulesUrlFor(Organization.Id); + + public virtual async Task InitializeAsync() + { + FeatureService.IsEnabled(FeatureFlagKeys.Pam).Returns(true); + + OwnerEmail = $"{_emailPrefix}-{Guid.NewGuid()}@bitwarden.com"; + await Factory.LoginWithNewAccount(OwnerEmail); + (Organization, _) = await OrganizationTestHelpers.SignUpAsync(Factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: OwnerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + } + + public virtual Task DisposeAsync() + { + Client.Dispose(); + return Task.CompletedTask; + } + + protected static string AccessRulesUrlFor(Guid organizationId) => $"organizations/{organizationId}/access-rules"; + + protected string AccessRuleUrl(Guid id) => $"{AccessRulesUrl}/{id}"; +} diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/Pam.IntegrationTest.csproj b/bitwarden_license/test/Services/Pam.IntegrationTest/Pam.IntegrationTest.csproj new file mode 100644 index 000000000000..3619167a4f68 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/Pam.IntegrationTest.csproj @@ -0,0 +1,37 @@ + + + + Bit.Services.Pam.IntegrationTest + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json b/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json new file mode 100644 index 000000000000..89010f03412b --- /dev/null +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/packages.lock.json @@ -0,0 +1,2096 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.0.1, )", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "NSubstitute": { + "type": "Direct", + "requested": "[5.1.0, )", + "resolved": "5.1.0", + "contentHash": "ZCqOP3Kpp2ea7QcLyjMU4wzE+0wmrMN35PQMsdPOHYc2IrvjmusG9hICOiqiOTPKN0gJon6wyCn6ZuGHdNs9hQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "AdaptiveCards": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "b+sPwH0oyAflpgxCyNPMzH92xrQjWl6GuuEBv86/VhO6iHhiWv+PtwzqMS70nOXZQRzpl9YVHXAvn+dKot5IBQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "AspNetCore.HealthChecks.SqlServer": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "sTcVVq7/zhfUrSTs0WAktvPdpU1He/sj14gRTogq4eFhn0oImolxNNhJczkYMgFF92RMMW+O+rlcFO7HVOpfiQ==", + "dependencies": { + "Microsoft.Data.SqlClient": "5.2.0", + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.0" + } + }, + "AspNetCore.HealthChecks.Uris": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "A1ahRx4pdXjrSGlGFLoyoXOV4Lfp5sfs+OIGfvi14RwecIAac4xs6cP0Q8tw/rv4Ng+KAaYpzD4qhxXVwUcIyA==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "AspNetCoreRateLimit": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "6fq9+o1maGADUmpK/PvcF0DtXW2+7bSkIL7MDIo/agbIHKN8XkMQF4oze60DO731WaQmHmK260hB30FwPzCmEg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "6.0.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.3", + "Microsoft.Extensions.Options": "6.0.0", + "Newtonsoft.Json": "13.0.2" + } + }, + "AspNetCoreRateLimit.Redis": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "3g6Mb4Y+rW14/oE7Qt8WFA9zS7XNdHx7TH3k/XQix7PtUWEZSSAK+VsqDhDIPkUysUHFBDUA7olNeTjytpzA/g==", + "dependencies": { + "AspNetCoreRateLimit": "5.0.0", + "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)" + } + }, + "AutoMapper": { + "type": "Transitive", + "resolved": "14.0.0", + "contentHash": "OC+1neAPM4oCCqQj3g2GJ2shziNNhOkxmNB9cVS8jtx4JbgmRzLcUOxB9Tsz6cVPHugdkHgCaCrTjjSI0Z5sCQ==", + "dependencies": { + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.3.3", + "contentHash": "YQv10JuxnciWh0QwnkarSbge4gXQV1qTURf5jkBjNUH/3jYS9QrbxopA4TK1qdjfOfP37tqiJkLSrRRNqX81aw==" + }, + "AWSSDK.SimpleEmail": { + "type": "Transitive", + "resolved": "4.0.2.5", + "contentHash": "LvV5mXlvpR3fTAJysO3KmUC6bR/KUZpdkcMJ5b6lYNpStlsFN+MXcaMh34TuwYaTCgIjF3bJb4oZifFkgh+Ccw==", + "dependencies": { + "AWSSDK.Core": "[4.0.3.3, 5.0.0)" + } + }, + "AWSSDK.SQS": { + "type": "Transitive", + "resolved": "4.0.2.5", + "contentHash": "bHA9m/2RZHNKt6NGvQ56rfEDj/pfUlcwPeCg2HmPJ3jZPyoerBuEsYvFkMP3YJNv3aoycNWZoiDk0/ULP0tEyA==", + "dependencies": { + "AWSSDK.Core": "[4.0.3.3, 5.0.0)" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.47.3", + "contentHash": "u/uCNtUWT+Q/Is7/PAMy3KP9kq5vY5klRnyAvRxO/kEa5OnV3/X5lHlCajNANC7vmej6jAqceqLBJNO/VyCKzg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.6.1", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Core.Amqp": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "AY1ZM4WwLBb9L2WwQoWs7wS2XKYg83tp3yVVdgySdebGN0FuIszuEqCy3Nhv6qHpbkjx/NGuOTsUbF/oNGBgwA==", + "dependencies": { + "Microsoft.Azure.Amqp": "2.6.7", + "System.Memory.Data": "1.0.2" + } + }, + "Azure.Data.Tables": { + "type": "Transitive", + "resolved": "12.11.0", + "contentHash": "MabH2HegMvZA1ocaMhEfW/idyTa3CoH64s43/V9/KFRGdVqEj0EETvd3ItDe6Bbs2teiR40KE9Kz9NLDc5DJJw==", + "dependencies": { + "Azure.Core": "1.44.1" + } + }, + "Azure.Extensions.AspNetCore.DataProtection.Blobs": { + "type": "Transitive", + "resolved": "1.3.4", + "contentHash": "zS+x0MpUMSbvZD598lwAoax+ohIeSAvGlXpT71iP7FFmMZ+Tjz/8hx+jZH/RbV2cJYTYbux8XFDll7LMPuz46g==", + "dependencies": { + "Azure.Core": "1.38.0", + "Azure.Storage.Blobs": "12.16.0", + "Microsoft.AspNetCore.DataProtection": "3.1.32" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.11.4", + "contentHash": "Sf4BoE6Q3jTgFkgBkx7qztYOFELBCo+wQgpYDwal/qJ1unBH73ywPztIJKXBXORRzAeNijsuxhk94h0TIMvfYg==", + "dependencies": { + "Azure.Core": "1.38.0", + "Microsoft.Identity.Client": "4.61.3", + "Microsoft.Identity.Client.Extensions.Msal": "4.61.3", + "System.Security.Cryptography.ProtectedData": "4.7.0" + } + }, + "Azure.Messaging.EventGrid": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/lc0X9Na9v8mb6cY8vRIaMuQEcerHK8msmwmc3nhkY9QX4x4R8w+sDxnHM0eXezzzmEumkfUPnDWIKCEzvsl9A==", + "dependencies": { + "Azure.Core": "1.46.2", + "Azure.Messaging.EventGrid.SystemEvents": "1.0.0" + } + }, + "Azure.Messaging.EventGrid.SystemEvents": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "sGAZL0Kw3ErcPPdN3+OcMM+fTCeyGtP/No0+D7PP4tCI5VqKB2DxOlu/tdF4UYuk6YXE3XAZ/yHDjmzEaqr59g==", + "dependencies": { + "Azure.Core": "1.46.2" + } + }, + "Azure.Messaging.ServiceBus": { + "type": "Transitive", + "resolved": "7.20.1", + "contentHash": "DxCkedWPQuiXrIyFcriOhsQcZmDZW+j9d55Ev4nnK3yjMUFjlVe4Hj37fuZTJlNhC3P+7EumqBTt33R6DfOxGA==", + "dependencies": { + "Azure.Core": "1.46.2", + "Azure.Core.Amqp": "1.3.1", + "Microsoft.Azure.Amqp": "2.7.0" + } + }, + "Azure.Storage.Blobs": { + "type": "Transitive", + "resolved": "12.26.0", + "contentHash": "EBRSHmI0eNzdufcIS1Rf7Ez9M8V1Jl7pMV4UWDERDMCv513KtAVsgz2ez2FQP9Qnwg7uEQrP+Uc7vBtumlr7sQ==", + "dependencies": { + "Azure.Core": "1.47.3", + "Azure.Storage.Common": "12.25.0" + } + }, + "Azure.Storage.Blobs.Batch": { + "type": "Transitive", + "resolved": "12.23.0", + "contentHash": "1Cj2/OEPoNpcwjQZ/vtng4ImrwuDlOZhYd3mKCxQXzUe50dl0lM5AWX8KE8GGKd5pLuRKYMNmn3mRvWpv/Me+A==", + "dependencies": { + "Azure.Core": "1.47.3", + "Azure.Storage.Blobs": "12.26.0", + "Azure.Storage.Common": "12.25.0" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.25.0", + "contentHash": "MHGWp4aLHRo0BdLj25U2qYdYK//Zz21k4bs3SVyNQEmJbBl3qZ8GuOmTSXJ+Zad93HnFXfvD8kyMr0gjA8Ftpw==", + "dependencies": { + "Azure.Core": "1.47.3", + "System.IO.Hashing": "8.0.0" + } + }, + "Azure.Storage.Queues": { + "type": "Transitive", + "resolved": "12.24.0", + "contentHash": "YSR051EMu421JZNCOyOB2JpVyA4bSW8CnbTYmYlwxsYIUJuwiMy2toSXIoq9RKG9PuBtnT5dS9M6QCYNGaswAw==", + "dependencies": { + "Azure.Core": "1.47.3", + "Azure.Storage.Common": "12.25.0" + } + }, + "BitPay.Light": { + "type": "Transitive", + "resolved": "1.0.1907", + "contentHash": "QTTIgXakHrRNQPxNyH7bZ7frm0bI8N6gRDtiqVyKG/QYQ+KfjN70xt0zQ0kO0zf8UBaKuwcV5B7vvpXtzR9ijg==", + "dependencies": { + "Newtonsoft.Json": "12.0.2" + } + }, + "Bitwarden.Server.Sdk.Environment": { + "type": "Transitive", + "resolved": "0.1.0", + "contentHash": "yLK3ik/+zQ4w41iDObvv4vq2QwWPaVMuyKFpzlgsukClOOuZUxJAivwnnZZDMXbp2wipWgaW4yeEY+yLdLVaVQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Bitwarden.Server.Sdk.Features": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "74Ir68CSZSUvZvVkb2CaUNHSAZcFMtZY5pReHz+K1eQX6+Am29iWS1i8DbQWq9F6KmCAEbqcGDyfAo4gRCurbg==", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "0.1.0", + "LaunchDarkly.ServerSdk": "8.14.1" + } + }, + "Bitwarden.Server.Sdk.WebEssentials": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "pMKQCluxCGueZ8DK9NS8Xfa+/oxplS9NoSxd7JrI573JsKKl7THe2yeB772S7yogzmqt+0n3GrvA56b8fKOIsQ==", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "0.1.0" + } + }, + "Bogus": { + "type": "Transitive", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Braintree": { + "type": "Transitive", + "resolved": "5.36.0", + "contentHash": "K43RjhEU5qXoYYxo0C0o54msQxbdRjVP+hDMZwSXsei4fLBHA2xwS1NCooZ9girCQNjoWOM8bygkHTVGgN+sag==", + "dependencies": { + "Newtonsoft.Json": "13.0.1", + "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", + "contentHash": "kqfTOZGrn7NarNeXgjh86JcpTHUoeQDMB8t9NVa/ZtlSYiV1rxfRnQ49WaJsob4AiGrbK0XDzpyKkBwai4F8eg==" + }, + "Dapper": { + "type": "Transitive", + "resolved": "2.1.66", + "contentHash": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==" + }, + "dbup-core": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "dbup-sqlserver": { + "type": "Transitive", + "resolved": "7.2.0", + "contentHash": "1xhdu2ZoQEi2nNrirBfkkfn+AbHWQvy8CGilb+5dIjghFJrsMKFM17DiI5Nz+ofWg9N1lqz5WorujzuGvc/+fQ==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.4", + "dbup-core": "6.1.1" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "RRwtaCXkXWsx0mmsReGDqCbRLtItfUbkRJlet1FpdciVhyMGKcPd57T1+8Jki9ojHlq9fntVhXQroOOgRak8DQ==" + }, + "Duende.IdentityModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "8i+Tv4c38LgwoTRKbD0+MqtnNNDSVA83G6JkjGHgC4/7jH0nxZBP0RBhH8xTsvNQ5Pv9zrg+TR8rmtWK9HDOPg==" + }, + "Duende.IdentityServer": { + "type": "Transitive", + "resolved": "7.4.6", + "contentHash": "Zvri5e+SrOWLz0wmJry0ZaU8gVygv966jzP/CbMSCtV0K/nqK7abL9gQr9aKKmjt5DsONauUMT2E0cK/GbXJAg==", + "dependencies": { + "Duende.IdentityServer.Storage": "7.4.6", + "Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0" + } + }, + "Duende.IdentityServer.Storage": { + "type": "Transitive", + "resolved": "7.4.6", + "contentHash": "qPNsoj5H1TaT5gYptA/Z5LZE/UT4PFsgDen8K1DLj4W9O8i1PuEfFiba9CbmwLPs/TUS2xikCbxfiUgBUqn8GQ==", + "dependencies": { + "Duende.IdentityModel": "8.0.0", + "Microsoft.AspNetCore.DataProtection.Abstractions": "10.0.0" + } + }, + "DuoUniversal": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "BZUJplORCBO1PVDFT5v7HDYAlpgHAkay5N9vzRpJ/sBm+GU44pxNlo7v95Ym0tvUeKy0WWWv/iE4FjErYoZUHQ==", + "dependencies": { + "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", + "contentHash": "S0Bz1vfcKlO4Jase3AWp5XnQ746psf4oGx5kL+D2A10j1SsjoAOAIIpanSwfi0cEepDHgk1bClcOKY5TjOzGdA==", + "dependencies": { + "Fido2.Models": "3.0.1", + "Microsoft.Extensions.Http": "6.0.0", + "NSec.Cryptography": "22.4.0", + "System.Formats.Cbor": "6.0.0", + "System.IdentityModel.Tokens.Jwt": "6.17.0" + } + }, + "Fido2.AspNet": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "5n5shEXD7RFUyTesjUHGDjkpgES7j4KotQo1GwUcS08k+fx+1tl/zCFHJ9RFDuUwO+S681ZILT2PyA67IPYpaA==", + "dependencies": { + "Fido2": "3.0.1", + "Fido2.Models": "3.0.1" + } + }, + "Fido2.Models": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "mgjcuGETuYSCUEaZG+jQeeuuEMkDLc4GDJHBvKDdOz6oSOWp5adPdWP4btZx7Pi+9fu4szN3JIjJmby67MaILw==" + }, + "Handlebars.Net": { + "type": "Transitive", + "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", + "contentHash": "0bEnUVFVeW1TTDXb/bW6kS3FLQTLeGtw7Xh8yt6WNO56utVmtgcrMLvcnF6yeTn+N4FXrKfW09KkLNmK8YYQvw==" + }, + "LaunchDarkly.CommonSdk": { + "type": "Transitive", + "resolved": "7.2.0", + "contentHash": "YLT9rl0ooxHVNWtSsKBjtqdBt8Ysg99sXHAO1G8dcSH4B6yv3qR0lyCo6GEb9EQpUuGdneGqO7Bs7K2B8F+lTg==", + "dependencies": { + "LaunchDarkly.Logging": "2.0.0" + } + }, + "LaunchDarkly.EventSource": { + "type": "Transitive", + "resolved": "5.3.1", + "contentHash": "bVXethnjYnjC+hYSHYhn+1QFZhwtD0NYy1MJjtfOfC6CBW9h1pzR0hEAf16DQYl/LJtMi7fwOJbQhq8lFlFrSw==", + "dependencies": { + "LaunchDarkly.Logging": "[2.0.0, 3.0.0)" + } + }, + "LaunchDarkly.InternalSdk": { + "type": "Transitive", + "resolved": "3.6.1", + "contentHash": "qkbqHzz3AruAPW5PfbwQEM883p2ArHG3xun8IONmBT/QdiAhLFqh/5vxMCUsT1GfPyyly4S0HxYx3GP01Gc3Pg==", + "dependencies": { + "LaunchDarkly.CommonSdk": "[7.2.0, 8.0.0)", + "LaunchDarkly.Logging": "[2.0.0, 3.0.0)" + } + }, + "LaunchDarkly.Logging": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "lsLKNqAZ7HIlkdTIrf4FetfRA1SUDE3WlaZQn79aSVkLjYWEhUhkDDK7hORGh4JoA3V2gXN+cIvJQax2uR/ijA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0" + } + }, + "LaunchDarkly.ServerSdk": { + "type": "Transitive", + "resolved": "8.14.1", + "contentHash": "ufdAP+8OYpLEnDJanErS7wXj9xRYKCY1ZXmxaYuQwTB0qik3DviMRy5ml6Ehjd8kGgWZzOnur5EwqTKM+wW/AQ==", + "dependencies": { + "LaunchDarkly.Cache": "1.0.2", + "LaunchDarkly.CommonSdk": "7.2.0", + "LaunchDarkly.EventSource": "5.3.1", + "LaunchDarkly.InternalSdk": "3.6.1", + "LaunchDarkly.Logging": "2.0.0" + } + }, + "libsodium": { + "type": "Transitive", + "resolved": "1.0.18.2", + "contentHash": "flArHoVdscSzyV8ZdPV+bqqY2TTFlaN+xZf/vIqsmHI51KVcD/mOdUPaK3n/k/wGKz8dppiktXUqSmf3AXFgig==" + }, + "linq2db": { + "type": "Transitive", + "resolved": "5.4.1", + "contentHash": "qyH32MbFK6T55KsEcQYTbPFfkOa1Mo65lY/Zo8SFVMy0pwkQBCTnA/RUxyG5+l3D/mgfPz85PH3upDrtklSMrw==" + }, + "linq2db.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "wEUTdkWsrtwlE3aAb4qmxNkjrZOVp39KBM+wPvEnTNXoSym6Po3u9/PWRWAsbJAGjoljv5604ACcCOp/yMJ5XQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "linq2db": "5.4.0" + } + }, + "MailKit": { + "type": "Transitive", + "resolved": "4.17.0", + "contentHash": "1nUAVLxM9fhT/78we6/AGsCesnpn5dRNLLeRqOfr52Wnk87pzVwo5YMTMyqnmoXrYc7piGhmayiMA/OgDluIjg==", + "dependencies": { + "MimeKit": "4.17.0" + } + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "oGnE+X/SN6jdqao9WOkOIfyZ5+a0AtluJWy1Mxndq+kcWG6sx5k6l6tucu8/wJ7o9fHfLgVCzm/c4v/KVgVk6w==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.Authentication.OpenIdConnect": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "6ATONu+5A2oh/vzmoFhf3cuQcclMaWGHrb1kvjVsYtml+gzuWD48MmbsItM4xAUQkJZ2t8XFmbGp8pZLPxKneA==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "R0TKX26vPlw4kfyaLECwxn5GUIUAv6B+5s8kiEku9cl9VVCrDQDslPuhUUhN6oI/TvLj1lMFkz6AhHIpbPC3Lg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "eKKLdOmdyr8TrzvD9eKdcvh8OlfomZiN6FwZNlK+F8fihZJBRgNVReqt3evYvZMHK3N/0Ui6P14cubllt1cFUg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.8" + } + }, + "Microsoft.AspNetCore.DataProtection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "Rlbrr3XSGB4dwnUY/pA70TpVyrQhelDAUkIiGfJ2Tm32mscTYxrRnk9Ooy1rRhGZ1g7rliJPuNzhyMMKmRH5pw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.8", + "Microsoft.AspNetCore.DataProtection.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "System.Security.Cryptography.Xml": "10.0.8" + } + }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "NYxEmhe2tDPwwGgl0kNraUOJdOqaIYJpnZ1Lf7OlqWRf3aLagniVxMl8JSVmy0jiRtElsPYq8lTvLlbvRSwCLA==" + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "C9kMpUciPgx7ObqoO6W+eXEf3zHFWb7XpQgFJBzdO8GsmmVYrgcErTLMuki6e3EihycGpHbcJECYHDgM7XRMkg==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Hosting": "10.0.8" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HRH/XAke90wkHv9ykCsrvpVqvKOUt53jQzvHHIXrPIPZWAjyPq6B5/InCmPYWvme+WKMXD10rplMAitzNMtC3w==" + }, + "Microsoft.Azure.Amqp": { + "type": "Transitive", + "resolved": "2.7.0", + "contentHash": "gm/AEakujttMzrDhZ5QpRz3fICVkYDn/oDG9SmxDP+J7R8JDBXYU9WWG7hr6wQy40mY+wjUF0yUGXDPRDRNJwQ==" + }, + "Microsoft.Azure.Cosmos": { + "type": "Transitive", + "resolved": "3.52.0", + "contentHash": "NEjNpaO19gvJrXowqHFcYPSpro5+TNjHO/JpU4VXP37by2aU2RVnmgpZsWL1GUl5wCPZ4VIOUsP1lrHpfW8ADQ==", + "dependencies": { + "Azure.Core": "1.44.1", + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "Microsoft.Bcl.HashCode": "1.1.0", + "System.Configuration.ConfigurationManager": "6.0.0" + } + }, + "Microsoft.Azure.NotificationHubs": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "LOCxFB/sB1frfuXjecdDRDKEHkH+I1qKRatS5NyWIgYhpKhIcAlPNIJajcwLgQBShckdc3hMG9E+75CnL3qDhQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "6.0.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w==" + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "J2G1k+u5unBV+aYcwxo94ip16Rkp65pgWFb0R6zwJipzWNMgvqlWeuI7/+R+e8bob66LnSG+llLJ+z8wI94cHg==" + }, + "Microsoft.Bot.Builder": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "bLTrp/tfSNWDAIJ90TqyZ8JtpjCvNO7kgvhW0R5eSu72tAifwEIi2uxhFS+c8alsa2DM4EW3JSemoDgn0r6Hog==", + "dependencies": { + "Microsoft.Bot.Connector": "4.23.0", + "Microsoft.Bot.Connector.Streaming": "4.23.0", + "Microsoft.Bot.Streaming": "4.23.0", + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Microsoft.Bot.Builder.Integration.AspNet.Core": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "p6xghjJfg3Vh/q2NSd77TtuvCykuEakzMaELHctf3Cw4eTILsXWIgEJ0QxyelMnJGJjgfBwFS9ZeC2hWUFYzBA==", + "dependencies": { + "Microsoft.Bot.Builder": "4.23.0", + "Microsoft.Bot.Configuration": "4.23.0", + "Microsoft.Bot.Connector.Streaming": "4.23.0", + "Microsoft.Bot.Streaming": "4.23.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Bot.Configuration": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "yCzxNU5QAEQ6zy7VBNuz3GwOY8OZcDkNYOmPw/QuVzViozxuJI200BMl+a5jhY9Nd7j6bGxO7Y3mmHy4Tu7Teg==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Bot.Connector": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "/vgAQ8LonwAnyu6CzYYElU4k65k+9V2ncwztpZtBM+IuwkhDe3iAO2ycObqcyhMMWYUV81IOb0JZYcabjiZ4NQ==", + "dependencies": { + "Microsoft.Bot.Schema": "4.23.0", + "Microsoft.Extensions.Http": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Identity.Client": "4.66.1", + "Microsoft.Identity.Web.Certificateless": "3.3.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.1.2", + "Microsoft.Rest.ClientRuntime": "2.3.24", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Bot.Connector.Streaming": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "Yz6PcySgtje88IGEJXj6agzya48pBL34/A5Zs3/xqmHvEQlI2ypMNc3OBOo2TRGxykklCSwc60PN2k95d4pbFw==", + "dependencies": { + "Microsoft.Bot.Schema": "4.23.0", + "Microsoft.Bot.Streaming": "4.23.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Bot.Schema": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "HZeEXg/PuniNRIUU8ioSx/LrOXcbTZCZ1hUIqDdc6akWwhjrC2sTv7TaBpD0FlY4hDyUvj3GLyurPI/YChGPzA==", + "dependencies": { + "AdaptiveCards": "3.1.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Bot.Streaming": { + "type": "Transitive", + "resolved": "4.23.0", + "contentHash": "gYudjsFAVjwZBRU5irJh7sdsD7gNRFfZEUwWTqkNp1eGaR1t+4sJY+YyqYL3PyCMSgLrKE46bt/JWuDi3CjRfA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "/oolEwtHuDtpLKU8OItOTTxVJalgPtIkcNBwzXJ3YGyrkOAvLYqMtin9Z1jxqryJpds3PjuZBF5iKIYVVYVSvQ==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "9.0.13", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "1.0.0", + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.13", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.13", + "System.Security.Cryptography.Pkcs": "9.0.13" + } + }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "rlnxc0KfwDSbE8ZHntFnl8SCgOa9QtJZblMv2zXLhRwl1Je7fsdsVzxSjzzC4JMsfAK+jXJWyezRB8SxUY4BdA==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "Kue/7CF8KNT9zozfr30C94dMZVZml3atqWZvQemSXvTau76tRdypzeKiBKXadqgbOME0UiQIyVTNo5WxCRNVNg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "qHInO2EvOcPhjgboP0TGnXM7rASdvWXrw6jAH8Yuz5YP82VTje7d/NKiX1i+dVbE3+G3JuW1kqNVB8yLvsqgYA==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.6" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "iK+jrJzkfbIxutB7or808BPmJtjUEi5O+eSM7cLDwsyde6+3iOujCSfWnrHrLxY3u+EQrJD+aD8DJ6ogPA2Rtw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.8", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.8", + "Microsoft.Extensions.Caching.Memory": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "9mMQkZsfL1c2iifBD8MWRmwy59rvsVtR9NOezJj7+g1j4P7g49MJHd8k8faC/v7d5KuHkQ6KOQiSItvoRt9PXA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "OlAXMU+VQgLz5y5/SBkLvAa9VeiR3dlJqgIebEEH2M2NGA3evm68/Tv7SLWmSxwnEAtA3nmDEZF2pacK6eXh4Q==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "3WnrwdXxKg4L98cDx0lNEEau8U2lsfuBJCs0Yzht+5XVTmahboM7MukKfQHAzVsHUPszm6ci929S7Qas0WfVHA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "IDB7Xs16hN/3VkWFCCa4r3fqoJxMVezwq418gr8dBkRBO0pxH+BX/Kjk/U3PYXDvzVLkXqUgJsHv1XoFrJbZPQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "8.0.8", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.6" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "w5k/ENj3+BPbmggqh83RRuPhhKcJmW7CmdJuGwdX1eFrmptJwnzKiHfQCPkJAu9df16PSs5YFeWrDgepfqnltA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "8.0.8", + "Microsoft.EntityFrameworkCore.Relational": "8.0.8", + "Microsoft.Extensions.DependencyModel": "8.0.1" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "A2F52W+hnGqvprx37HcAnYnJv4QoFFdc9cxd/QGNSd1vCu1I0eAEKRd0r9KS3E5I5RRj/m9XJfYCyTdy1cdn5Q==", + "dependencies": { + "Microsoft.Data.SqlClient": "5.1.5", + "Microsoft.EntityFrameworkCore.Relational": "8.0.8" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Caching.Cosmos": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "8UI41/U5yla1z48klbtRdXxxloAChRzhAm592bitBNzTlXBq87zeO6Lbdvuh5d6oLNCU0I01kw6ovTD9Z6y05g==", + "dependencies": { + "Microsoft.Azure.Cosmos": "3.47.0", + "Microsoft.Extensions.Caching.Abstractions": "6.0.0", + "Microsoft.Extensions.Options": "6.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "OdQmN8LYcUEu20Fxii9mk68nHJGL+JPXF3w0+hxenf0oDDdDBA+ZV/S92FmIgAWAElowIiFA/g0x+8YB1g80Hg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.13", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.13", + "Microsoft.Extensions.Logging.Abstractions": "9.0.13", + "Microsoft.Extensions.Options": "9.0.13", + "Microsoft.Extensions.Primitives": "9.0.13" + } + }, + "Microsoft.Extensions.Caching.SqlServer": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "GSP1UIw/VFiLOWBOCQYwLHsLnkqqqqEC9sc8IqCXpbSkwz03EZ9u4jcFORTE4TJ/gkKXKP6L3AO+ZFZ5MFX4Gg==", + "dependencies": { + "Azure.Identity": "1.11.4", + "Microsoft.Data.SqlClient": "5.2.2", + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fle9ns3q2kk63vt/wHFtLw1U9kiEbM42vTk2Sar8VBjiGJFkXAgm0QEKFx15YMHkJIPRVdknWaovpvgGEgn10g==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "9.10.0", + "contentHash": "Tgu40iIg2Kr8s+BoOhb8r8kQfcagwm1VnpnMZA9fd/sD8Hlj13cNpyCfLRrYEBP+VmfmaoficQvRNEUqH+F4mw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10", + "Microsoft.Extensions.ObjectPool": "9.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Json": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "P9SoBuVZhJPpALZmSq72aQEb9ryP67EdquaCZGXGrrcASTNHYdrUhnpgSwIipgM5oVC+dKpRXg5zxobmF9xr5g==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AT2qqos3IgI09ok36Qag9T8bb6kHJ3uT9Q5ki6CySybFsK6/9JbvQAgAHf1pVEjST0/N4JaFaCbm40R5edffwg==" + }, + "Microsoft.Extensions.Diagnostics.Testing": { + "type": "Transitive", + "resolved": "9.10.0", + "contentHash": "p8XnKg4yZRRpORwm5VvoBZbHeEQUmJM6OMg4psJpClkyeVLP6sc+/bqR2rnfRUyP7yhIzJB/Tmulrg6mTt8dBw==", + "dependencies": { + "Microsoft.Extensions.Logging": "9.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.10", + "Microsoft.Extensions.Telemetry.Abstractions": "9.10.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", + "Microsoft.Extensions.Configuration.Json": "10.0.8", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Logging.Console": "10.0.8", + "Microsoft.Extensions.Logging.Debug": "10.0.8", + "Microsoft.Extensions.Logging.EventLog": "10.0.8", + "Microsoft.Extensions.Logging.EventSource": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "ZOuH3nlDslon9a0kJSRBTWnHNCKoiOoaurc3H1F4D6xLT+4UDvBNAqlLkEFyQdcxZFyUvYdwgc1+D/EjsD+RXA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Identity.Stores": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "xVbg4qLWyjKSJVxtL56PQPlHu/URpWPKufhfOj61+tkCmNs6DIgnGxG8BAO/fAfacoBDDYg+p1zBjFzzj/EQog==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.Identity.Core": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "System.Diagnostics.EventLog": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "tw0jYoEdRp2AQMBYTkdCy0OKWcNaazaFQgo4KzdayTkX2N00g2hAacGd9mls4nBz6clP+87eeD0ucWyDrz+VKg==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "9.10.0", + "contentHash": "hJflG5if8NqElmybxXDf38d4EPopOo9H+Qg6l5LKTsavqE4CFdA5DIPb9+jjAeL22FN+rs6KuuEIuBPS4PNXvw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "9.10.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "Microsoft.Extensions.ObjectPool": "9.0.10", + "Microsoft.Extensions.Options": "9.0.10" + } + }, + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Transitive", + "resolved": "10.6.0", + "contentHash": "qQDiaYWpvIymGbu+kXaMDS8YdqfeQkv6DOxPF2GSwC+eSzIKqOOnSP34TYt7gKqvB7p8/aSptexnW6nF0CUdnw==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.66.1", + "contentHash": "mE+m3pZ7zSKocSubKXxwZcUrCzLflC86IdLxrVjS8tialy0b1L+aECBqRBC/ykcPlB4y7skg49TaTiA+O2UfDw==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.61.3", + "contentHash": "PWnJcznrSGr25MN8ajlc2XIDW4zCFu0U6FkpaNLEWLgd1NgFCp5uDY3mqLDgM8zCN8hqj8yo5wHYfLB2HjcdGw==", + "dependencies": { + "Microsoft.Identity.Client": "4.61.3", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.Identity.Web.Certificateless": { + "type": "Transitive", + "resolved": "3.3.0", + "contentHash": "ybEVPCLeJFuTCDVTtt3OlD5n+CYQgUAzmn0YZw+Z4NR5XwB4iGQ/zMqQ+ruJfgoKGWe6BTl0vCfsv1O4XPqCvg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Identity.Client": "4.66.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.1.2" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "u7QhXCISMQuab3flasb1hoaiERmUqyWsW7tmQODyILoQ7mJV5IRGM+2KKZYo0QUfC13evEOcHAb6TPWgqEQtrw==" + }, + "Microsoft.Rest.ClientRuntime": { + "type": "Transitive", + "resolved": "2.3.24", + "contentHash": "hZH7XgM3eV2jFrnq7Yf0nBD4WVXQzDrer2gEY7HMNiwio2hwDsTHO6LWuueNQAfRpNp4W7mKxcXpwXUiuVIlYw==", + "dependencies": { + "Newtonsoft.Json": "10.0.3" + } + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "MimeKit": { + "type": "Transitive", + "resolved": "4.17.0", + "contentHash": "h/KXsCreJf8RpR/PSAtlbvYtZFndp9N8Wn1Bs248AL7ITyhn+AiIY5bLTvt60tbN2mu6g8KadtBNWygTji2lqg==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "System.Security.Cryptography.Pkcs": "10.0.0" + } + }, + "MySqlConnector": { + "type": "Transitive", + "resolved": "2.3.5", + "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "7.0.1" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "8.0.4", + "contentHash": "/hHd9MqTRVDgIpsToCcxMDxZqla0HAQACiITkq1+L9J2hmHKV6lBAPlauF+dlNSfHpus7rrljWx4nAanKD6qAw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.4", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.4", + "Microsoft.EntityFrameworkCore.Relational": "8.0.4", + "Npgsql": "8.0.3" + } + }, + "NSec.Cryptography": { + "type": "Transitive", + "resolved": "22.4.0", + "contentHash": "lEntcPYd7h3aZ8xxi/y/4TML7o8w0GEGqd+w4L1omqFLbdCBmhxJAeO2YBmv/fXbJKgKCQLm7+TD4bR605PEUQ==", + "dependencies": { + "libsodium": "[1.0.18.2, 1.0.19)" + } + }, + "OneOf": { + "type": "Transitive", + "resolved": "3.0.271", + "contentHash": "pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg==" + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.3" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==", + "dependencies": { + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "Transitive", + "resolved": "1.15.2", + "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "1.12.0-beta.2", + "contentHash": "4D2PLiJWbBbQbauojkIflT11WGVXoRU+xgox1mvOkpfm7YXIfwTtROOlcdscS51sMh5fgwjGKJtLWpLKppe7dw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.12.0, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Http": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Runtime": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==", + "dependencies": { + "OpenTelemetry.Api": "[1.15.3, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.SqlClient": { + "type": "Transitive", + "resolved": "1.15.2", + "contentHash": "pRB84YJEXD121kygc6CtcF46dtLuTKIjphA2SJye32G4g2Xl3epkCrOcPCOAg4WDagCdMGa16Ge6PPVzFJZsAQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "Otp.NET": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "Fk1NKc0lWmlo6LAFYpFJInRgFKt72knRNEvxndDYoQHFwYOPXav+WEUBvQA0k4lxq5xt0SymrZ+oi0F/G40bPQ==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Pomelo.EntityFrameworkCore.MySql": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "XjnlcxVBLnEMbyEc5cZzgZeDyLvAniACZQ04W1slWN0f4rmfNzl98gEMvHnFH0fMDF06z9MmgGi/Sr7hJ+BVnw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "[8.0.2, 8.0.999]", + "MySqlConnector": "2.3.5" + } + }, + "Quartz": { + "type": "Transitive", + "resolved": "3.15.1", + "contentHash": "XIbhzUAKSm3xdl1ORLPnK7mc5XANP3cuvYQhCtuX/8888IN41e9OXJak4R9OlmAGRnyAMqHE40yojVa89NS1wg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "2.1.1" + } + }, + "Quartz.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "3.15.1", + "contentHash": "LinB9z54aPn49C/DGM1v3OflX2nosrEo4zNz10vfYqcCndFJ8MNU9k++Ap9T7vxeZc355WStPDggpX60TYj1Lg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Quartz": "3.15.1" + } + }, + "Quartz.Extensions.Hosting": { + "type": "Transitive", + "resolved": "3.15.1", + "contentHash": "svqLTEnVLb0VPUcNCd/khRqagwxM/yybUZ2sEOd7HFdPO+5dAOttL+ARtXSyBeaGWPWAaxY4VvU7pJTZzYhORw==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "9.0.0", + "Quartz.Extensions.DependencyInjection": "3.15.1" + } + }, + "RabbitMQ.Client": { + "type": "Transitive", + "resolved": "7.1.2", + "contentHash": "y3c6ulgULScWthHw5PLM1ShHRLhxg0vCtzX/hh61gRgNecL3ZC3WoBW2HYHoXOVRqTl99Br9E7CZEytGZEsCyQ==", + "dependencies": { + "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", + "contentHash": "nb/zHePecN9U4/Bmct+O+lpgK994JklbCCNMIgGPOone/DngjQoMCHeTvkl+m0Nglvm0dqMEshmvB4fO8eF3dA==", + "dependencies": { + "Newtonsoft.Json": "13.0.1", + "starkbank-ecdsa": "[1.3.3, 2.0.0)" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "2.10.0", + "contentHash": "+QX0hmf37a0/OZLxM3wL7V6/ADvC1XihXN4Kq/p6d8lCPfgkRdiuhbWlMaFjR9Av0dy5F0+MBeDmDdRZN/YwQA==" + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "IWfem7wfrFbB3iw1OikqPFNPEzfayvDuN4WP7Ue1AVFskalMByeWk3QbtUXQR34SBkv1EbZ3AySHda/ErDgpcg==", + "dependencies": { + "Microsoft.Extensions.Logging": "2.0.0", + "Serilog": "2.9.0" + } + }, + "Serilog.Extensions.Logging.File": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "bUYjMHn7NhpK+/8HDftG7+G5hpWzD49XTSvLoUFZGgappDa6FoseqFOsLrjLRjwe1zM+igH5mySFJv3ntb+qcg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "6.0.0", + "Microsoft.Extensions.Configuration.Binder": "6.0.0", + "Serilog": "2.10.0", + "Serilog.Extensions.Logging": "3.1.0", + "Serilog.Formatting.Compact": "1.1.0", + "Serilog.Sinks.Async": "1.5.0", + "Serilog.Sinks.RollingFile": "3.3.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "pNroKVjo+rDqlxNG5PXkRLpfSCuDOBY0ri6jp9PLe505ljqwhwZz8ospy2vWhQlFu5GkIesh3FcDs4n7sWZODA==", + "dependencies": { + "Serilog": "2.8.0" + } + }, + "Serilog.Sinks.Async": { + "type": "Transitive", + "resolved": "1.5.0", + "contentHash": "csHYIqAwI4Gy9oAhXYRwxGrQEAtBg3Ep7WaCzsnA1cZuBZjVAU0n7hWaJhItjO7hbLHh/9gRVxALCUB4Dv+gZw==", + "dependencies": { + "Serilog": "2.9.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "3.2.0", + "contentHash": "VHbo68pMg5hwSWrzLEdZv5b/rYmIgHIRhd4d5rl8GnC5/a8Fr+RShT5kWyeJOXax1el6mNJ+dmHDOVgnNUQxaw==", + "dependencies": { + "Serilog": "2.3.0" + } + }, + "Serilog.Sinks.RollingFile": { + "type": "Transitive", + "resolved": "3.3.0", + "contentHash": "2lT5X1r3GH4P0bRWJfhA7etGl8Q2Ipw9AACvtAHWRUSpYZ42NGVyHoVs2ALBZ/cAkkS+tA4jl80Zie144eLQPg==", + "dependencies": { + "Serilog.Sinks.File": "3.2.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "BmAf6XWt4TqtowmiWe4/5rRot6GerAeklmOPfviOvwLoF5WwgxcJHAxZtySuyW9r9w+HLILnm8VfJFLCUJYW8A==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.6", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.6" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "wO6v9GeMx9CUngAet8hbO7xdm+M42p1XeJq47ogyRoYSvNSp0NGLI+MgC0bhrMk9C17MTVFlLiN6ylyExLCc5w==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "PQ2Oq3yepLY4P7ll145P3xtx2bX8xF4PzaKPRpw9jZlKvfe4LE/saAV82inND9usn1XRpmxXk7Lal3MTI+6CNg==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.6" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.8.31", + "contentHash": "RCHVQa9Zke8k0oBgJn1Yl6BuYy8i6kv+sdMObiH60nOwD6QvWAjxdDwOm+LO78E8WsGiPqgOuItkz98fPS6haQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "starkbank-ecdsa": { + "type": "Transitive", + "resolved": "1.3.3", + "contentHash": "OblOaKb1enXn+dSp7tsx9yjwV+/BEKM9jFhshIkZTwCk7LuTFTp+wSon6rFzuPiIiTGtvVWQNUw2slHjGktJog==" + }, + "Stripe.net": { + "type": "Transitive", + "resolved": "48.5.0", + "contentHash": "wOAZYR0EnrLMok/ScfVOpTxjci+n3vFP0A7w/BE63yJdkRSDwZVCJIhlOjeJvgyQnMX8ZbwDAHMaxaiDa0Z5TA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "System.Configuration.ConfigurationManager": "8.0.0" + } + }, + "Swashbuckle.AspNetCore": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", + "dependencies": { + "Microsoft.OpenApi": "2.4.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.1.7" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "xcHHhDqB5MnOOY8yIn64Vzp6gtBEs6k5J1hluG04CrShSvQNXOx4PSDs7wJiXLDidlY/FZJmxJdKTKskyJwjvw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "GbBrJq9S/gYpHzm7Pxx6Y5tDyfSfyxW6tlP5oiKJV38uf19Wp+GIIAnWfyL1zmNiz1+EjwVapw2WkBFvvqKQzg==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.13", + "System.Security.Cryptography.ProtectedData": "9.0.13" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw==" + }, + "System.Formats.Cbor": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "mGaLOoiw7KurJagOOcIsWUoCT5ACIiGxKlCcbYQASefBGXjnCcKTq5Hdjb94eEAKg38zXKlHw4c6EjzgBl9dIw==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ne1843evDugl0md7Fjzy6QjJrzsjh46ZKbhf8GwBXb5f/gw97J4bxMs0NQKifDuThh/f0bZ0e62NPl1jzTuRqA==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "2wOycCqMyg9Tu+SDP03FFwDEWBni/3xOKgt0bRXplOvyeIcUJmWO7m3gTCF2mIdtQLROLtOP5VwWRT8YBwP/bA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "t8S9IDpjJKsLpLkeBdW8cWtcPyYqrGu93Dej1RO6WwuL/lkFSqWlan3rMJfortqz1mRIh+sys2AFsSA6jWJ3Jg==" + }, + "System.Security.Cryptography.Xml": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "Fb+L55vEJaf8RCOzrzN564sfyCL8SZEfce9z6XkuhHg+294SBfyS4fIoLU3EljofDCkp+EKDeleI8ug5WO2NtA==", + "dependencies": { + "System.Security.Cryptography.Pkcs": "10.0.8" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "System.Xml.XPath.XmlDocument": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "A/uxsWi/Ifzkmd4ArTLISMbfFs6XpRPsXZonrIqyTY70xi8t+mDtvSM5Os0RqyRDobjMBwIDHDL4NOIbkDwf7A==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "YubicoDotNetClient": { + "type": "Transitive", + "resolved": "1.2.0", + "contentHash": "uP5F3Ko1gqZi3lwS2R/jAAwhBxXs/6PKDpS6FdQjsBA5qmF0hQmbtfxM6QHTXOMoWbUtfetG7+LtgmG8T5zDIg==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "ZiggyCreatures.FusionCache": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "nO6ysiVP/1S1zVZMzsK0xASeSUay27iIlK8GjeyTpIAmq5P4/0KOzV9AqlabZYFgzeQDAu5IcB39ela2w/HCwQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1" + } + }, + "ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "E9KfGnhY+xcy8bmxoB/jJbfYwBlQwDD6c0v0P/Qr3IxGyJXyncza/45vFo5nI+5CggqczQ1GwQeEJqk0Lg8Q5g==", + "dependencies": { + "StackExchange.Redis": "2.8.31", + "ZiggyCreatures.FusionCache": "2.0.2" + } + }, + "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "gt5ia5PHpCxhnI0hr51Y6L/acrrU17OuBqiU3vJPFXZxS3R1pIj2hr6WGB5fzW64VZDVdHTYsD5gTr2Pu8I+QQ==", + "dependencies": { + "ZiggyCreatures.FusionCache": "2.0.2" + } + }, + "api": { + "type": "Project", + "dependencies": { + "AspNetCore.HealthChecks.SqlServer": "[8.0.2, 8.0.2]", + "AspNetCore.HealthChecks.Uris": "[8.0.1, 8.0.1]", + "Azure.Messaging.EventGrid": "[5.0.0, 5.0.0]", + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.4.0, )", + "Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )", + "Commercial.Core": "[2026.8.0, )", + "Commercial.Infrastructure.EntityFramework": "[2026.8.0, )", + "Core": "[2026.8.0, )", + "HttpExtensions": "[2026.8.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", + "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )", + "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", + "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", + "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", + "OrganizationAuthorization": "[0.0.1, )", + "Pam": "[2026.8.0, )", + "SharedWeb": "[2026.8.0, )", + "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]" + } + }, + "api.integrationtest": { + "type": "Project", + "dependencies": { + "Api": "[2026.8.0, )", + "IntegrationTestCommon": "[2026.8.0, )", + "Microsoft.Extensions.Diagnostics.Testing": "[9.10.0, 9.10.0]", + "Microsoft.NET.Test.Sdk": "[18.0.1, )", + "Seeder": "[2026.8.0, )", + "xunit": "[2.6.6, )" + } + }, + "commercial.core": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.0, )", + "CsvHelper": "[33.1.0, 33.1.0]" + } + }, + "commercial.infrastructure.entityframework": { + "type": "Project", + "dependencies": { + "AutoMapper": "[14.0.0, 14.0.0]", + "Core": "[2026.8.0, )", + "Infrastructure.EntityFramework": "[2026.8.0, )" + } + }, + "common": { + "type": "Project", + "dependencies": { + "AutoFixture.AutoNSubstitute": "[4.18.1, )", + "AutoFixture.Xunit2": "[4.18.1, )", + "Core": "[2026.8.0, )", + "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": { + "type": "Project", + "dependencies": { + "AWSSDK.SQS": "[4.0.2.5, 4.0.2.5]", + "AWSSDK.SimpleEmail": "[4.0.2.5, 4.0.2.5]", + "AspNetCoreRateLimit": "[5.0.0, 5.0.0]", + "AspNetCoreRateLimit.Redis": "[2.0.0, 2.0.0]", + "Azure.Data.Tables": "[12.11.0, 12.11.0]", + "Azure.Extensions.AspNetCore.DataProtection.Blobs": "[1.3.4, 1.3.4]", + "Azure.Messaging.ServiceBus": "[7.20.1, 7.20.1]", + "Azure.Storage.Blobs": "[12.26.0, 12.26.0]", + "Azure.Storage.Blobs.Batch": "[12.23.0, 12.23.0]", + "Azure.Storage.Queues": "[12.24.0, 12.24.0]", + "BitPay.Light": "[1.0.1907, 1.0.1907]", + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.4.0, )", + "Braintree": "[5.36.0, 5.36.0]", + "CsvHelper": "[33.1.0, 33.1.0]", + "Data": "[0.0.1, )", + "DnsClient": "[1.8.0, 1.8.0]", + "Duende.IdentityServer": "[7.4.6, 7.4.6]", + "DuoUniversal": "[1.3.1, 1.3.1]", + "ExceptionHandling": "[0.0.1, )", + "Fido2.AspNet": "[3.0.1, 3.0.1]", + "Handlebars.Net": "[2.1.6, 2.1.6]", + "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]", + "Microsoft.Azure.NotificationHubs": "[4.2.0, 4.2.0]", + "Microsoft.Bot.Builder": "[4.23.0, 4.23.0]", + "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", + "Microsoft.Bot.Connector": "[4.23.0, 4.23.0]", + "Microsoft.Data.SqlClient": "[7.0.0, 7.0.0]", + "Microsoft.Extensions.Caching.Cosmos": "[1.8.0, 1.8.0]", + "Microsoft.Extensions.Caching.SqlServer": "[10.0.8, 10.0.8]", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.8, 10.0.8]", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "[10.0.8, 10.0.8]", + "Microsoft.Extensions.Configuration.UserSecrets": "[10.0.8, 10.0.8]", + "Microsoft.Extensions.Identity.Stores": "[10.0.8, 10.0.8]", + "Newtonsoft.Json": "[13.0.3, 13.0.3]", + "OneOf": "[3.0.271, 3.0.271]", + "Otp.NET": "[1.4.0, 1.4.0]", + "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]", + "RabbitMQ.Client": "[7.1.2, 7.1.2]", + "SendGrid": "[9.29.3, 9.29.3]", + "SerilogFileLogging": "[0.0.1, )", + "SsrfProtection": "[0.0.1, )", + "Stripe.net": "[48.5.0, 48.5.0]", + "YubicoDotNetClient": "[1.2.0, 1.2.0]", + "ZiggyCreatures.FusionCache": "[2.0.2, 2.0.2]", + "ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": "[2.0.2, 2.0.2]", + "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" + } + }, + "data": { + "type": "Project" + }, + "exceptionhandling": { + "type": "Project", + "dependencies": { + "HttpExtensions": "[2026.8.0, )" + } + }, + "httpextensions": { + "type": "Project" + }, + "identity": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.4.0, )", + "Bitwarden.Server.Sdk.WebEssentials": "[0.5.0, )", + "Core": "[2026.8.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", + "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )", + "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", + "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )", + "OpenTelemetry.Instrumentation.SqlClient": "[1.15.2, )", + "SharedWeb": "[2026.8.0, )" + } + }, + "infrastructure.dapper": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.0, )", + "Dapper": "[2.1.66, 2.1.66]", + "Pam.Domain": "[2026.8.0, )" + } + }, + "infrastructure.entityframework": { + "type": "Project", + "dependencies": { + "AutoMapper": "[14.0.0, 14.0.0]", + "Core": "[2026.8.0, )", + "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", + "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", + "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]", + "Pam.Domain": "[2026.8.0, )", + "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]", + "linq2db": "[5.4.1, 5.4.1]", + "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]" + } + }, + "integrationtestcommon": { + "type": "Project", + "dependencies": { + "Common": "[2026.8.0, )", + "Identity": "[2026.8.0, )", + "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", + "Migrator": "[2026.8.0, )", + "Seeder": "[2026.8.0, )" + } + }, + "migrator": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.0, )", + "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", + "dbup-sqlserver": "[7.2.0, 7.2.0]" + } + }, + "organizationauthorization": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.0, )" + } + }, + "pam": { + "type": "Project", + "dependencies": { + "Core": "[2026.8.0, )", + "HttpExtensions": "[2026.8.0, )", + "OrganizationAuthorization": "[0.0.1, )", + "Pam.Domain": "[2026.8.0, )" + } + }, + "pam.domain": { + "type": "Project", + "dependencies": { + "Data": "[0.0.1, )" + } + }, + "rustsdk": { + "type": "Project" + }, + "seeder": { + "type": "Project", + "dependencies": { + "Bogus": "[35.6.5, 35.6.5]", + "Core": "[2026.8.0, )", + "Infrastructure.EntityFramework": "[2026.8.0, )", + "RustSdk": "[2026.8.0, )", + "SharedWeb": "[2026.8.0, )" + } + }, + "serilogfilelogging": { + "type": "Project", + "dependencies": { + "Serilog.Extensions.Logging.File": "[3.0.0, 3.0.0]" + } + }, + "sharedweb": { + "type": "Project", + "dependencies": { + "Bitwarden.Server.Sdk.Environment": "[0.1.0, )", + "Bitwarden.Server.Sdk.Features": "[1.4.0, )", + "Core": "[2026.8.0, )", + "Infrastructure.Dapper": "[2026.8.0, )", + "Infrastructure.EntityFramework": "[2026.8.0, )", + "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", + "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" + } + }, + "ssrfprotection": { + "type": "Project" + } + } + } +} \ No newline at end of file From b1df3720f49a96d9328b043c7eb416275fcaba4d Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 11 Aug 2026 11:57:55 +0200 Subject: [PATCH 10/13] Cover the access-rule endpoints end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command and validator tests mock every seam these endpoints are assembled from, so nothing so far asserted that a rule survives the trip through HTTP binding, the endpoint filters and the database. These tests pin the parts no unit test can reach: - the conditions document is stored verbatim and read back unchanged, including properties this version does not model — the engine reading it later sees what the client sent - collection links are written and cleared as the governed set changes, across two separate repository writes - a validator failure arrives as the documented ErrorResponseModel 400 rather than a 500, which is what having the exception filter outermost in the group's chain buys - a missing required field is rejected by the group's validation filter before any handler runs, instead of being dereferenced into a 500 - a rule ID from another organization is neither readable nor deletable through this organization's route — authorization only establishes membership, so the handler's scoping check is the only thing standing there - the whole surface is unroutable with the PAM feature flag off - timestamps carry a UTC designator, without which a JavaScript client reads the instant as local time Reading a rule whose stored conditions no longer parse is covered too, since that path is deliberately forgiving and worth pinning: it reads back as no conditions, which the engine treats as satisfied. --- .../AccessRuleCrudTests.cs | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleCrudTests.cs diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleCrudTests.cs b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleCrudTests.cs new file mode 100644 index 000000000000..28eb7a1f30df --- /dev/null +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleCrudTests.cs @@ -0,0 +1,280 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Pam.Entities; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Api.Models.Request; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.IntegrationTest; + +/// +/// The access-rule CRUD contract over the real request pipeline: routing, the feature gate, model validation, the +/// exception → ErrorResponseModel translation, and the round trip through SQLite. +/// +/// +/// These tests cover the seams that the Pam.Test unit tests necessarily mock away — the conditions document surviving +/// HTTP binding and storage unchanged, the collection links actually being written, and validator failures arriving as +/// the documented 400 body. Authorization is 's subject; every test here +/// acts as the organization owner. +/// +public class AccessRuleCrudTests(ApiApplicationFactory factory) + : AccessRuleIntegrationTestBase(factory, "pam-access-rule-crud") +{ + private const string HumanApproval = """[{"kind":"human_approval","approverCount":1}]"""; + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await LoginHelper.LoginAsync(OwnerEmail); + } + + [Fact] + public async Task AccessRule_CreateListGetUpdateDelete_RoundTripsOverTheApi() + { + var created = await PostRuleAsync(NewRule("Production database")); + var id = created["id"]!.GetValue(); + Assert.Equal("accessRule", created["object"]!.GetValue()); + Assert.Equal(Organization.Id, created["organizationId"]!.GetValue()); + + var list = await GetJsonAsync(AccessRulesUrl); + Assert.Equal("list", list["object"]!.GetValue()); + Assert.Contains(list["data"]!.AsArray(), rule => rule!["id"]!.GetValue() == id); + + var fetched = await GetJsonAsync(AccessRuleUrl(id)); + Assert.Equal("Production database", fetched["name"]!.GetValue()); + Assert.True(fetched["enabled"]!.GetValue()); + + var updated = await PutRuleAsync(id, NewRule("Production database (paused)", enabled: false)); + Assert.Equal(id, updated["id"]!.GetValue()); + Assert.Equal("Production database (paused)", updated["name"]!.GetValue()); + Assert.False(updated["enabled"]!.GetValue()); + + var deleteResponse = await Client.DeleteAsync(AccessRuleUrl(id)); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + + var afterDelete = await Client.GetAsync(AccessRuleUrl(id)); + Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); + } + + /// + /// The conditions document is stored verbatim and handed back unparsed, so the engine that reads it later sees + /// exactly what the client sent — including properties this version does not model. + /// + [Fact] + public async Task Post_StoresTheConditionsDocumentVerbatim() + { + const string conditions = + """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8","192.168.1.0/24"],"unmodelled":"kept"}]"""; + + var created = await PostRuleAsync(NewRule("Office network only", conditions)); + + Assert.Equal(JsonNode.Parse(conditions)!.ToJsonString(), created["conditions"]!.ToJsonString()); + + var fetched = await GetJsonAsync(AccessRuleUrl(created["id"]!.GetValue())); + Assert.Equal(JsonNode.Parse(conditions)!.ToJsonString(), fetched["conditions"]!.ToJsonString()); + } + + [Fact] + public async Task Post_GovernsTheRequestedCollections_AndPutReplacesTheSet() + { + var governed = await OrganizationTestHelpers.CreateCollectionAsync(Factory, Organization.Id, "Governed"); + var other = await OrganizationTestHelpers.CreateCollectionAsync(Factory, Organization.Id, "Other"); + + var created = await PostRuleAsync(NewRule("Governs one collection", collections: [governed.Id])); + var id = created["id"]!.GetValue(); + + Assert.Equal(new[] { governed.Id }, created["collections"]!.AsArray().Select(c => c!.GetValue()).ToArray()); + Assert.Equal(id, await AccessRuleIdOfAsync(governed.Id)); + + await PutRuleAsync(id, NewRule("Governs the other collection", collections: [other.Id])); + + Assert.Null(await AccessRuleIdOfAsync(governed.Id)); + Assert.Equal(id, await AccessRuleIdOfAsync(other.Id)); + } + + /// + /// A validator failure has to surface as Bitwarden's ErrorResponseModel 400 rather than a 500, which is + /// what the exception filter being outermost in the PAM group's chain buys. + /// + [Fact] + public async Task Post_WithACidrThatDoesNotParse_ReturnsBadRequestWithTheValidatorMessage() + { + var response = await Client.PostAsJsonAsync(AccessRulesUrl, + NewRule("Bad allowlist", """[{"kind":"ip_allowlist","cidrs":["not-a-cidr"]}]""")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await ReadJsonAsync(response); + Assert.Equal("error", body["object"]!.GetValue()); + Assert.Contains("not-a-cidr", body["message"]!.GetValue()); + } + + /// + /// Conditions is declared required, so an omitted value has to be rejected by the group's validation filter + /// before any handler runs — not dereferenced into a 500. + /// + [Fact] + public async Task Post_WithoutConditions_ReturnsBadRequestFromModelValidation() + { + var response = await Client.PostAsJsonAsync(AccessRulesUrl, + new { name = "No conditions", collections = Array.Empty() }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await ReadJsonAsync(response); + Assert.Equal("The model state is invalid.", body["message"]!.GetValue()); + Assert.Contains(nameof(AccessRuleRequestModel.Conditions), + body["validationErrors"]!.AsObject().Select(error => error.Key)); + } + + /// + /// Authorization only proves the caller belongs to the organization on the route, so the handler is what stops a + /// rule ID from another organization being read through it. + /// + [Fact] + public async Task Get_ARuleBelongingToAnotherOrganization_ReturnsNotFound() + { + var foreignRule = await SeedRuleInAnotherOrganizationAsync(); + + var response = await Client.GetAsync(AccessRuleUrl(foreignRule.Id)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Delete_ARuleBelongingToAnotherOrganization_ReturnsNotFound() + { + var foreignRule = await SeedRuleInAnotherOrganizationAsync(); + + var response = await Client.DeleteAsync(AccessRuleUrl(foreignRule.Id)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + Assert.NotNull(await Factory.GetService().GetByIdAsync(foreignRule.Id)); + } + + /// + /// The whole surface is unreleased and reachable only behind the PAM flag. + /// + [Fact] + public async Task AccessRuleEndpoints_WithThePamFeatureFlagOff_AreNotRoutable() + { + FeatureService.IsEnabled(FeatureFlagKeys.Pam).Returns(false); + + Assert.Equal(HttpStatusCode.NotFound, (await Client.GetAsync(AccessRulesUrl)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, + (await Client.PostAsJsonAsync(AccessRulesUrl, NewRule("Should not be reachable"))).StatusCode); + } + + [Fact] + public async Task Post_RecordsTheCallingUserAsTheLastEditor() + { + var created = await PostRuleAsync(NewRule("Attributed to the owner")); + + var owner = await Factory.GetService().GetByEmailAsync(OwnerEmail); + var stored = await Factory.GetService() + .GetByIdAsync(created["id"]!.GetValue()); + + Assert.Equal(owner!.Id, stored!.LastEditedBy); + } + + /// + /// A kind-less DateTime serializes with no timezone designator, which a JavaScript client reads as local time — + /// shifting the instant for any client not sitting on UTC. + /// + [Fact] + public async Task Post_ReturnsTimestampsMarkedAsUtc() + { + var created = await PostRuleAsync(NewRule("Timestamped")); + + Assert.EndsWith("Z", created["creationDate"]!.GetValue(), StringComparison.Ordinal); + Assert.EndsWith("Z", created["revisionDate"]!.GetValue(), StringComparison.Ordinal); + } + + /// + /// Rules predating a conditions-format change still have to be readable, so a document that no longer parses + /// reads back as no conditions instead of failing the request. + /// + [Fact] + public async Task Get_WithStoredConditionsThatNoLongerParse_ReturnsNullConditions() + { + var rule = await Factory.GetService().CreateAsync(new AccessRule + { + OrganizationId = Organization.Id, + Name = "Unparseable conditions", + Conditions = "{ not json", + }); + + var fetched = await GetJsonAsync(AccessRuleUrl(rule.Id)); + + Assert.Null(fetched["conditions"]); + } + + private static object NewRule( + string name, + string conditions = HumanApproval, + bool enabled = true, + Guid[]? collections = null) => new + { + name, + enabled, + conditions = JsonNode.Parse(conditions), + collections = collections ?? [], + }; + + private async Task PostRuleAsync(object rule) + { + var response = await Client.PostAsJsonAsync(AccessRulesUrl, rule); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response); + } + + private async Task PutRuleAsync(Guid id, object rule) + { + var response = await Client.PutAsJsonAsync(AccessRuleUrl(id), rule); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response); + } + + private async Task GetJsonAsync(string url) + { + var response = await Client.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) => + (await response.Content.ReadFromJsonAsync())!; + + private async Task AccessRuleIdOfAsync(Guid collectionId) => + (await Factory.GetService().GetByIdAsync(collectionId))!.AccessRuleId; + + private async Task SeedRuleInAnotherOrganizationAsync() + { + var otherOwnerEmail = $"pam-other-org-{Guid.NewGuid()}@bitwarden.com"; + await Factory.LoginWithNewAccount(otherOwnerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(Factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: otherOwnerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + // Seeded through the repository rather than the API: the point is a rule this caller can name but must not + // reach, and logging in as the other organization's owner would only get in the way. + var rule = await Factory.GetService().CreateAsync(new AccessRule + { + OrganizationId = otherOrganization.Id, + Name = "Another organization's rule", + Conditions = "[]", + }); + + await LoginHelper.LoginAsync(OwnerEmail); + return rule; + } +} From 94d9b8659ac4fa1bc9d3bf87bb9b402ca5d6d174 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 11 Aug 2026 11:58:25 +0200 Subject: [PATCH 11/13] Cover the access-rule handler and API models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler stopped being plumbing when the endpoints moved authorization into the middleware: what stayed behind is resource scoping — a rule reached by ID has to belong to the organization on the route — and stamping the calling user as the rule's last editor. Neither had a test, so losing either would have been silent. The request and response models were untested as well, including the branches that decide what a client sees when a stored conditions document does not parse, what gets stored when conditions arrive as something other than a JsonElement, and that response timestamps are relabelled UTC rather than shifted. --- .../AccessRuleEndpointsHandlerTests.cs | 144 ++++++++++++++++++ .../Api/Models/AccessRuleRequestModelTests.cs | 78 ++++++++++ .../Models/AccessRuleResponseModelTests.cs | 84 ++++++++++ 3 files changed, 306 insertions(+) create mode 100644 bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleRequestModelTests.cs create mode 100644 bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleResponseModelTests.cs diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs new file mode 100644 index 000000000000..63bc45f2f93a --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Pam.Entities; +using Bit.Pam.Models; +using Bit.Pam.Repositories; +using Bit.Services.Pam.Api.Endpoints.Handlers; +using Bit.Services.Pam.Api.Models.Request; +using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Endpoints.Handlers; + +/// +/// Whether the caller may touch the organization at all is settled by the authorization middleware before a handler +/// runs (see AccessRuleEndpoints). What is left to the handler — and so what these tests pin — is resource +/// scoping, that a rule reached by ID belongs to the organization on the route, and the edit attribution handed to +/// the commands. +/// +[SutProviderCustomize] +public class AccessRuleEndpointsHandlerTests +{ + [Theory, BitAutoData] + public async Task GetAll_ReturnsTheOrganizationsRules( + Guid organizationId, + AccessRuleDetails first, + AccessRuleDetails second, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetManyDetailsByOrganizationIdAsync(organizationId) + .Returns(new List { first, second }); + + var result = await sutProvider.Sut.GetAll(organizationId); + + Assert.Equal(new[] { first.Id, second.Id }, result.Data.Select(rule => rule.Id).ToArray()); + } + + [Theory, BitAutoData] + public async Task Get_ReturnsTheRule( + AccessRuleDetails rule, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetDetailsByIdAsync(rule.Id) + .Returns(rule); + + var result = await sutProvider.Sut.Get(rule.OrganizationId, rule.Id); + + Assert.Equal(rule.Id, result.Id); + } + + /// + /// Membership in the route's organization is all the middleware establishes, so nothing but this check stops a + /// rule ID from one organization being read through another organization's route. + /// + [Theory, BitAutoData] + public async Task Get_ARuleBelongingToAnotherOrganization_ThrowsNotFound( + AccessRuleDetails rule, Guid otherOrganizationId, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetDetailsByIdAsync(rule.Id) + .Returns(rule); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Get(otherOrganizationId, rule.Id)); + } + + [Theory, BitAutoData] + public async Task Get_AMissingRule_ThrowsNotFound( + Guid organizationId, Guid id, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetDetailsByIdAsync(id) + .Returns((AccessRuleDetails?)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Get(organizationId, id)); + } + + [Theory, BitAutoData] + public async Task Post_CreatesTheRuleForTheRouteOrganization_StampedWithTheCallingUser( + Guid organizationId, + Guid userId, + AccessRuleDetails created, + SutProvider sutProvider) + { + var model = RequestModel(); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .CreateAsync(Arg.Any(), Arg.Any>()) + .Returns(created); + + await sutProvider.Sut.Post(organizationId, model); + + await sutProvider.GetDependency().Received(1) + .CreateAsync( + Arg.Is(rule => rule.OrganizationId == organizationId && rule.LastEditedBy == userId), + model.Collections); + } + + [Theory, BitAutoData] + public async Task Put_UpdatesTheRule_StampedWithTheCallingUser( + Guid organizationId, + Guid id, + Guid userId, + AccessRuleDetails updated, + SutProvider sutProvider) + { + var model = RequestModel(); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .UpdateAsync(organizationId, id, Arg.Any(), Arg.Any>()) + .Returns(updated); + + await sutProvider.Sut.Put(organizationId, id, model); + + await sutProvider.GetDependency().Received(1) + .UpdateAsync( + organizationId, + id, + Arg.Is(rule => rule.OrganizationId == organizationId && rule.LastEditedBy == userId), + model.Collections); + } + + [Theory, BitAutoData] + public async Task Delete_DeletesWithinTheRouteOrganization( + Guid organizationId, Guid id, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(userId); + + await sutProvider.Sut.Delete(organizationId, id); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, id, userId); + } + + private static AccessRuleRequestModel RequestModel() => new() + { + Name = "Production database", + Conditions = JsonDocument.Parse("[]").RootElement, + Collections = new List { Guid.NewGuid() }, + }; +} diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleRequestModelTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleRequestModelTests.cs new file mode 100644 index 000000000000..9eaf5cce312c --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleRequestModelTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Services.Pam.Api.Models.Request; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Models; + +public class AccessRuleRequestModelTests +{ + [Fact] + public void ToAccessRule_CopiesTheEditableFieldsOntoTheRouteOrganization() + { + var organizationId = Guid.NewGuid(); + var model = new AccessRuleRequestModel + { + Name = "Production database", + Description = "Requires an approver", + Enabled = false, + Conditions = Parse("[]"), + SingleActiveLease = true, + DefaultLeaseDurationSeconds = 900, + MaxLeaseDurationSeconds = 3600, + AllowsExtensions = true, + MaxExtensionDurationSeconds = 300, + Collections = [Guid.NewGuid()], + }; + + var rule = model.ToAccessRule(organizationId); + + Assert.Equal(organizationId, rule.OrganizationId); + Assert.Equal("Production database", rule.Name); + Assert.Equal("Requires an approver", rule.Description); + Assert.False(rule.Enabled); + Assert.True(rule.SingleActiveLease); + Assert.Equal(900, rule.DefaultLeaseDurationSeconds); + Assert.Equal(3600, rule.MaxLeaseDurationSeconds); + Assert.True(rule.AllowsExtensions); + Assert.Equal(300, rule.MaxExtensionDurationSeconds); + } + + /// + /// The conditions document is persisted as the client sent it rather than round-tripped through the condition + /// types, so anything this version does not model still reaches whoever reads the rule back. + /// + [Fact] + public void ToAccessRule_StoresTheConditionsDocumentVerbatim() + { + const string conditions = + """[{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"],"unmodelled":"kept"}]"""; + + var rule = NewModel(Parse(conditions)).ToAccessRule(Guid.NewGuid()); + + Assert.Equal(JsonNode.Parse(conditions)!.ToJsonString(), JsonNode.Parse(rule.Conditions)!.ToJsonString()); + Assert.Contains("unmodelled", rule.Conditions, StringComparison.Ordinal); + } + + /// + /// Conditions is bound as object, which is a over the wire but an ordinary CLR + /// value for anything constructing the model in process. The fallback has to serialize that value rather than + /// store its ToString(). + /// + [Fact] + public void ToAccessRule_SerializesConditionsThatAreNotAJsonElement() + { + var rule = NewModel(new[] { new { kind = "human_approval" } }).ToAccessRule(Guid.NewGuid()); + + Assert.Equal("""[{"kind":"human_approval"}]""", rule.Conditions); + } + + private static AccessRuleRequestModel NewModel(object conditions) => new() + { + Name = "Production database", + Conditions = conditions, + Collections = [], + }; + + private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement; +} diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleResponseModelTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleResponseModelTests.cs new file mode 100644 index 000000000000..e2da2c4b048e --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessRuleResponseModelTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Bit.Pam.Models; +using Bit.Services.Pam.Api.Models.Response; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Models; + +public class AccessRuleResponseModelTests +{ + [Fact] + public void Constructor_ReturnsTheStoredConditionsAsJson() + { + const string conditions = """[{"kind":"human_approval","approverCount":1}]"""; + + var model = new AccessRuleResponseModel(Details(conditions)); + + Assert.Equal(JsonValueKind.Array, model.Conditions!.Value.ValueKind); + Assert.Equal("human_approval", model.Conditions.Value[0].GetProperty("kind").GetString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Constructor_WithoutStoredConditions_ReturnsNullConditions(string? conditions) + { + var model = new AccessRuleResponseModel(Details(conditions!)); + + Assert.Null(model.Conditions); + } + + /// + /// A rule stored before a conditions-format change has to stay readable, so an unparseable document reads back as + /// no conditions rather than failing the request. Note the engine treats a rule with no conditions as satisfied, + /// which makes this a fail-open read — deliberate, and worth knowing about when the format next changes. + /// + [Fact] + public void Constructor_WithStoredConditionsThatDoNotParse_ReturnsNullConditions() + { + var model = new AccessRuleResponseModel(Details("{ not json")); + + Assert.Null(model.Conditions); + } + + /// + /// Dapper materializes these timestamps with , which serializes without a + /// timezone designator and is then read as local time by a JavaScript client. The stored values are already UTC + /// instants, so the kind is relabelled — the clock must not move. + /// + [Fact] + public void Constructor_MarksTheTimestampsAsUtcWithoutShiftingThem() + { + var stored = new DateTime(2026, 6, 15, 13, 0, 0, DateTimeKind.Unspecified); + var details = Details("[]"); + details.CreationDate = stored; + details.RevisionDate = stored; + + var model = new AccessRuleResponseModel(details); + + Assert.Equal(DateTimeKind.Utc, model.CreationDate.Kind); + Assert.Equal(DateTimeKind.Utc, model.RevisionDate.Kind); + Assert.Equal(stored.TimeOfDay, model.CreationDate.TimeOfDay); + Assert.Equal(stored.TimeOfDay, model.RevisionDate.TimeOfDay); + } + + [Fact] + public void Constructor_ReturnsTheGovernedCollections() + { + var collectionId = Guid.NewGuid(); + var details = Details("[]"); + details.CollectionIds = [collectionId]; + + var model = new AccessRuleResponseModel(details); + + Assert.Equal(new[] { collectionId }, model.Collections.ToArray()); + } + + private static AccessRuleDetails Details(string conditions) => new() + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Name = "Production database", + Conditions = conditions, + }; +} From 556b5501362c99c5aed57765da54df739cdbb4fe Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 11 Aug 2026 12:06:23 +0200 Subject: [PATCH 12/13] Drop the unused deletedBy from the access-rule delete command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeleteAsync took a deletedBy that nothing read. It existed to stamp ActorId onto the PAM audit event, and the audit surface was cut from this slice — the rule is hard deleted, so there is nowhere left to record who did it either. An interface asking for an actor implies deletes are attributed when they are not, and the handler was passing the current user in to satisfy a parameter that dropped it on the floor. It comes back when the audit event does, together with the code that reads it. --- .../Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs | 2 +- .../Commands/DeleteAccessRuleCommand.cs | 2 +- .../Commands/Interfaces/IDeleteAccessRuleCommand.cs | 2 +- .../Handlers/AccessRuleEndpointsHandlerTests.cs | 9 +++++---- .../Pam.Test/Commands/DeleteAccessRuleCommandTests.cs | 8 ++++---- 5 files changed, 12 insertions(+), 11 deletions(-) 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 053d459a00cf..948d5a155ca7 100644 --- a/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs +++ b/bitwarden_license/src/Services/Pam/Api/Endpoints/Handlers/AccessRuleEndpointsHandler.cs @@ -60,6 +60,6 @@ public async Task Put(Guid orgId, Guid id, AccessRuleRe public async Task Delete(Guid orgId, Guid id) { - await deleteCommand.DeleteAsync(orgId, id, currentContext.UserId); + await deleteCommand.DeleteAsync(orgId, id); } } diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs index d3eb327b1047..6196e6b0c944 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/DeleteAccessRuleCommand.cs @@ -13,7 +13,7 @@ public DeleteAccessRuleCommand(IAccessRuleRepository repository) _repository = repository; } - public async Task DeleteAsync(Guid organizationId, Guid id, Guid? deletedBy) + public async Task DeleteAsync(Guid organizationId, Guid id) { var existing = await _repository.GetByIdAsync(id); if (existing is null || existing.OrganizationId != organizationId) diff --git a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs index 0a490a96b092..89dc56bda016 100644 --- a/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs +++ b/bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/IDeleteAccessRuleCommand.cs @@ -2,5 +2,5 @@ public interface IDeleteAccessRuleCommand { - Task DeleteAsync(Guid organizationId, Guid id, Guid? deletedBy); + Task DeleteAsync(Guid organizationId, Guid id); } diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs index 63bc45f2f93a..62c4203cbabf 100644 --- a/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Api/Endpoints/Handlers/AccessRuleEndpointsHandlerTests.cs @@ -123,16 +123,17 @@ await sutProvider.GetDependency().Received(1) model.Collections); } + /// + /// The route's organization is what scopes the delete — the command rejects an ID belonging to any other. + /// [Theory, BitAutoData] public async Task Delete_DeletesWithinTheRouteOrganization( - Guid organizationId, Guid id, Guid userId, SutProvider sutProvider) + Guid organizationId, Guid id, SutProvider sutProvider) { - sutProvider.GetDependency().UserId.Returns(userId); - await sutProvider.Sut.Delete(organizationId, id); await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationId, id, userId); + .DeleteAsync(organizationId, id); } private static AccessRuleRequestModel RequestModel() => new() diff --git a/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs b/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs index 886aacb90f0a..3307488a42ba 100644 --- a/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs +++ b/bitwarden_license/test/Services/Pam.Test/Commands/DeleteAccessRuleCommandTests.cs @@ -14,13 +14,13 @@ public class DeleteAccessRuleCommandTests { [Theory, BitAutoData] public async Task DeleteAsync_HappyPath_HardDeletes( - AccessRule existing, Guid deletedBy, SutProvider sutProvider) + AccessRule existing, SutProvider sutProvider) { sutProvider.GetDependency() .GetByIdAsync(existing.Id) .Returns(existing); - await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id, deletedBy); + await sutProvider.Sut.DeleteAsync(existing.OrganizationId, existing.Id); await sutProvider.GetDependency().Received(1) .DeleteAsync(existing); @@ -35,7 +35,7 @@ public async Task DeleteAsync_MissingExisting_ThrowsNotFound( .Returns((AccessRule?)null); await Assert.ThrowsAsync( - () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid())); + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), Guid.NewGuid())); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().DeleteAsync(default!); } @@ -49,7 +49,7 @@ public async Task DeleteAsync_WrongOrg_ThrowsNotFound( .Returns(existing); await Assert.ThrowsAsync( - () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id, Guid.NewGuid())); + () => sutProvider.Sut.DeleteAsync(Guid.NewGuid(), existing.Id)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs().DeleteAsync(default!); } From 42988310ad580bdfc734db29ffec2e7981df7275 Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 11 Aug 2026 12:07:26 +0200 Subject: [PATCH 13/13] fmt --- .../Pam.IntegrationTest/AccessRuleAuthorizationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs index aacf96ee18b7..f4cf20767465 100644 --- a/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs +++ b/bitwarden_license/test/Services/Pam.IntegrationTest/AccessRuleAuthorizationTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers;