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