diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs
new file mode 100644
index 000000000000..d3b130491042
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs
@@ -0,0 +1,71 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace Bit.Services.Pam.Api.Models.Request;
+
+///
+/// Base class for access conditions. Each condition is discriminated by the kind field,
+/// which may appear at any position in the object — the server enables
+/// (see
+/// AddPamServices) to match the SDK's serde tagging, which accepts the tag anywhere.
+/// Supported kinds: human_approval (bare object, no payload) and ip_allowlist
+/// (requires a non-empty cidrs list of canonical, host-bit-free CIDR strings). An unknown
+/// kind — and any unknown member within a known kind — is rejected at deserialization time with
+/// (fail-closed, surfaced as a 400 by request
+/// binding): silently dropping an unrecognized constraint would enforce the condition more
+/// loosely than the caller intended.
+///
+/// Deliberately concrete, not abstract: a payload with no kind binds to this base
+/// type instead of throwing — which would escape request
+/// binding as a 500 — and is then rejected by with
+/// a structured 400. Do not instantiate it outside of that binding fallback.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
+[JsonDerivedType(typeof(HumanApprovalConditionModel), "human_approval")]
+[JsonDerivedType(typeof(IpAllowlistConditionModel), "ip_allowlist")]
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public class AccessConditionModel { }
+
+///
+/// A condition requiring human approval before access is granted.
+/// No additional payload — the presence of this condition in the rule's list is sufficient.
+///
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public class HumanApprovalConditionModel : AccessConditionModel { }
+
+///
+/// A condition restricting access to requests originating from one of the listed CIDR ranges.
+/// The cidrs list must contain between 1 and 100 entries; each entry must be a canonical,
+/// host-bit-free CIDR in the form address/prefix.
+///
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public class IpAllowlistConditionModel : AccessConditionModel, IValidatableObject
+{
+ ///
+ /// Between 1 and 100 CIDR ranges (e.g. 10.0.0.0/8, 2001:db8::/32).
+ /// Each entry must be a canonical, host-bit-free CIDR string.
+ ///
+ [Required]
+ [MinLength(1, ErrorMessage = "An IP allowlist condition must contain at least one CIDR range.")]
+ [MaxLength(100, ErrorMessage = "An IP allowlist condition may contain at most 100 CIDR ranges.")]
+ public List Cidrs { get; set; } = [];
+
+ ///
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ for (var i = 0; i < Cidrs.Count; i++)
+ {
+ var cidr = Cidrs[i];
+ // The length cap blocks degenerate-but-parseable forms (arbitrarily zero-padded
+ // prefixes like "10.0.0.0/000...08") from being persisted; every legitimate CIDR fits
+ // in 50 characters. The failing entry is identified by index only — user input must
+ // stay out of error messages and logs.
+ if (cidr is { Length: > 256 } || !CidrValidator.IsValid(cidr))
+ {
+ yield return new ValidationResult(
+ $"Cidrs[{i}] is not a valid canonical CIDR range.",
+ [$"{nameof(Cidrs)}[{i}]"]);
+ }
+ }
+ }
+}
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..51f20b2d986f 100644
--- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs
+++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs
@@ -1,9 +1,27 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
namespace Bit.Services.Pam.Api.Models.Request;
-public class AccessRuleRequestModel
+public class AccessRuleRequestModel : IValidatableObject
{
+ // Conditions are carried on the wire as verbatim JSON and stored verbatim, so the generated
+ // SDK clients bind them as an opaque value rather than a typed union (openapi-generator turns a
+ // oneOf into a lossy untagged enum). Server-side we still validate fail-closed by decoding that
+ // JSON a second time into the typed AccessConditionModel union in Validate() — see below. These
+ // options mirror the binding defaults (camelCase) and, like the SDK's serde tagging, accept the
+ // `kind` discriminator at any position in the object.
+ private static readonly JsonSerializerOptions ConditionsSerializerOptions =
+ new(JsonSerializerDefaults.Web)
+ {
+ AllowOutOfOrderMetadataProperties = true,
+ };
+
+ ///
+ /// The maximum number of conditions a single rule may carry.
+ ///
+ private const int MaxConditions = 10;
+
///
/// The rule's display name, shown wherever rules are listed and managed. Required; up to 256 characters.
///
@@ -23,12 +41,17 @@ 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 govern when this access rule permits a request — for example requiring human
+ /// approval, or restricting to certain source IPs. Carried and stored as verbatim JSON: an array of
+ /// kind-tagged objects ({"kind":"human_approval"}, {"kind":"ip_allowlist","cidrs":[...]}).
+ /// The array itself is required (an explicitly empty array means the rule imposes no conditions); its
+ /// contents are validated fail-closed server-side by decoding each entry into a typed condition union.
+ /// Supported kinds: human_approval (bare object, no payload) and ip_allowlist (a non-empty
+ /// cidrs list of canonical, host-bit-free CIDR strings). Unknown kinds, unknown members, and
+ /// malformed payloads are rejected. Maximum 10 conditions.
///
[Required]
- public object Conditions { get; set; } = null!;
+ public JsonElement? Conditions { get; set; }
///
/// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users).
@@ -65,4 +88,92 @@ public class AccessRuleRequestModel
///
[Required]
public IEnumerable Collections { get; set; } = null!;
+
+ ///
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ // A null/absent conditions field is handled by [Required]; nothing more to do here.
+ if (Conditions is not { } conditions || conditions.ValueKind == JsonValueKind.Null)
+ {
+ yield break;
+ }
+
+ if (conditions.ValueKind != JsonValueKind.Array)
+ {
+ yield return new ValidationResult(
+ "Conditions must be an array of condition objects.",
+ [nameof(Conditions)]);
+ yield break;
+ }
+
+ if (conditions.GetArrayLength() > MaxConditions)
+ {
+ yield return new ValidationResult(
+ $"A rule may have at most {MaxConditions} conditions.",
+ [nameof(Conditions)]);
+ }
+
+ var index = 0;
+ foreach (var element in conditions.EnumerateArray())
+ {
+ var member = $"{nameof(Conditions)}[{index}]";
+ index++;
+
+ AccessConditionModel? condition = null;
+ var decodeFailed = false;
+ try
+ {
+ // The second decode: verbatim JSON -> typed union. Unknown kinds, unknown members,
+ // non-object elements, and non-string discriminators all fail closed here rather
+ // than being silently accepted (which would enforce a condition more loosely than
+ // the caller intended).
+ condition = element.Deserialize(ConditionsSerializerOptions);
+ }
+ catch (JsonException)
+ {
+ decodeFailed = true;
+ }
+
+ if (decodeFailed)
+ {
+ // The offending input is identified by index only — user input must not appear in
+ // error messages or logs.
+ yield return new ValidationResult($"{member} is not a valid condition.", [member]);
+ continue;
+ }
+
+ // A JSON null element decodes to null.
+ if (condition is null)
+ {
+ yield return new ValidationResult($"{member} must not be null.", [member]);
+ continue;
+ }
+
+ // A payload with no `kind` binds to the concrete base type (see AccessConditionModel);
+ // reject it rather than accepting a meaningless no-op condition.
+ if (condition.GetType() == typeof(AccessConditionModel))
+ {
+ yield return new ValidationResult(
+ $"{member} must include a 'kind' discriminator.",
+ [member]);
+ continue;
+ }
+
+ // Recurse into the decoded condition — Validator does not walk nested objects — and
+ // prefix member names with the index so callers can identify which item failed.
+ var conditionContext = new ValidationContext(condition);
+ var conditionResults = new List();
+ if (!Validator.TryValidateObject(condition, conditionContext, conditionResults, validateAllProperties: true))
+ {
+ foreach (var result in conditionResults)
+ {
+ var memberNames = result.MemberNames
+ .Select(m => $"{member}.{m}")
+ .DefaultIfEmpty(member)
+ .ToArray();
+ yield return new ValidationResult(result.ErrorMessage, memberNames);
+ }
+ }
+ }
+ }
}
diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs
new file mode 100644
index 000000000000..7d2585651ade
--- /dev/null
+++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs
@@ -0,0 +1,130 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Bit.Services.Pam.Api.Models.Request;
+
+///
+/// Validates CIDR range strings using the same rules as the Bitwarden SDK's is_valid_cidr:
+/// canonical address form, a plain decimal prefix in the valid range for the address family, and
+/// no host bits set.
+///
+/// IPv4: the address must round-trip through unchanged
+/// (rejects leading-zero octets, hex octets, and partial addresses).
+///
+/// IPv6: any RFC-4291 textual form that accepts is
+/// valid as long as it contains no zone ID (%), no bracketed form ([/]), and
+/// is not an IPv4-mapped address (::ffff:a.b.c.d) — the SDK rejects mapped addresses as
+/// ambiguous with the native IPv4 range. Leading zeros in hextets and uncompressed forms such as
+/// 2001:0db8::/32 or 2001:db8:0:0:0:0:0:0/32 are accepted — matching Rust's
+/// Ipv6Addr::from_str, which compares by value.
+///
+internal static class CidrValidator
+{
+ ///
+ /// Returns when is a valid CIDR range:
+ /// address/prefix where the address parses for its family (see class-level rules),
+ /// the prefix is a plain decimal integer in the valid range (0–32 for IPv4, 0–128 for IPv6),
+ /// and no host bits are set (e.g. 10.0.0.0/8 is valid; 10.0.0.1/8 is not).
+ /// A value returns .
+ ///
+ public static bool IsValid(string? value)
+ {
+ // JSON binding can produce null list entries; treat them as invalid rather than throwing.
+ if (value is null)
+ {
+ return false;
+ }
+
+ // Split on the first '/' only — mirrors Rust's split_once('/').
+ var slashIndex = value.IndexOf('/');
+ if (slashIndex < 0)
+ {
+ return false;
+ }
+
+ var addrPart = value[..slashIndex];
+ var prefixPart = value[(slashIndex + 1)..];
+
+ // Empty address or empty prefix are invalid.
+ if (addrPart.Length == 0 || prefixPart.Length == 0)
+ {
+ return false;
+ }
+
+ // Parse prefix. byte covers 0–255, sufficient for both IPv4 (max 32) and IPv6 (max 128).
+ // NumberStyles.None rejects signs, whitespace, and any non-digit (e.g. the "8/8" prefix
+ // part of "10.0.0.0/8/8") while still accepting leading zeros such as "08" — matching the
+ // Rust SDK's u8 parsing (test prefix_with_leading_zero_is_valid).
+ if (!byte.TryParse(prefixPart, NumberStyles.None, CultureInfo.InvariantCulture, out var prefix))
+ {
+ return false;
+ }
+
+ // Reject IPv6 zone IDs (e.g. "fe80::1%eth0") before calling TryParse. Rust's
+ // Ipv6Addr::from_str rejects zone IDs; the server must match.
+ if (addrPart.Contains('%'))
+ {
+ return false;
+ }
+
+ // Reject bracketed IPv6 forms (e.g. "[::1]") — Rust rejects those too.
+ if (addrPart.Contains('[') || addrPart.Contains(']'))
+ {
+ return false;
+ }
+
+ if (!IPAddress.TryParse(addrPart, out var ip))
+ {
+ return false;
+ }
+
+ return ip.AddressFamily switch
+ {
+ // IPv4: round-trip through ToString() to reject non-canonical forms such as
+ // leading-zero octets ("010.0.0.0") and partial addresses ("1.2.3"). IPAddress.Parse
+ // accepts these on some platforms; the round-tripped string differs from the input.
+ AddressFamily.InterNetwork =>
+ string.Equals(ip.ToString(), addrPart, StringComparison.OrdinalIgnoreCase)
+ && prefix <= 32
+ && NoHostBits(ip.GetAddressBytes(), 32 - prefix),
+
+ // IPv6: skip the round-trip check. Rust's Ipv6Addr::from_str accepts any RFC-4291
+ // textual form (leading zeros in hextets, uncompressed, etc.) and compares by value,
+ // so "2001:0db8::/32" and "2001:db8:0:0:0:0:0:0/32" are valid there. Requiring a
+ // round-trip here would make the server reject inputs the SDK accepts, causing a
+ // client-passes-then-server-400 failure mode. Zone IDs and bracketed forms are already
+ // rejected above before reaching TryParse. IPv4-mapped addresses (::ffff:a.b.c.d) are
+ // rejected to match the SDK (to_ipv4_mapped().is_none(), test
+ // ipv4_mapped_ipv6_is_invalid) — the mapped form is ambiguous with the native IPv4
+ // range, and accepting it here would let rules exist that SDK clients refuse to edit.
+ AddressFamily.InterNetworkV6 =>
+ !ip.IsIPv4MappedToIPv6
+ && prefix <= 128
+ && NoHostBits(ip.GetAddressBytes(), 128 - prefix),
+
+ _ => false,
+ };
+ }
+
+ ///
+ /// Returns when the low bits of the
+ /// big-endian address (4 bytes for IPv4, 16 for IPv6) are all zero.
+ ///
+ private static bool NoHostBits(byte[] addressBytes, int hostBits)
+ {
+ // Walk from the last byte backward, checking that the low hostBits bits are all zero.
+ var remaining = hostBits;
+ for (var i = addressBytes.Length - 1; i >= 0 && remaining > 0; i--)
+ {
+ var bitsInByte = Math.Min(remaining, 8);
+ var mask = (byte)((1 << bitsInByte) - 1);
+ if ((addressBytes[i] & mask) != 0)
+ {
+ return false;
+ }
+ remaining -= bitsInByte;
+ }
+ return true;
+ }
+}
diff --git a/bitwarden_license/src/Services/Pam/Pam.csproj b/bitwarden_license/src/Services/Pam/Pam.csproj
index 264ff0119b1a..229518764fd1 100644
--- a/bitwarden_license/src/Services/Pam/Pam.csproj
+++ b/bitwarden_license/src/Services/Pam/Pam.csproj
@@ -28,4 +28,8 @@
+
+
+
+
diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs
new file mode 100644
index 000000000000..7018432f4793
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs
@@ -0,0 +1,352 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using Bit.Services.Pam.Api.Models.Request;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Api.Models;
+
+///
+/// Tests for the hierarchy: serialization round-trips,
+/// polymorphic deserialization, and the fail-closed "double decode" validation path in
+/// , where verbatim-JSON conditions are decoded into the typed
+/// union and validated server-side.
+///
+public class AccessConditionModelTests
+{
+ // The same options AccessRuleRequestModel decodes conditions with: Web defaults (camelCase)
+ // plus AllowOutOfOrderMetadataProperties so the 'kind' discriminator is accepted at any
+ // position, matching the SDK's serde tagging.
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ AllowOutOfOrderMetadataProperties = true,
+ };
+
+ // -------------------------------------------------------------------------
+ // Serialization tests
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void HumanApproval_SerializesTo_BareKindOnly()
+ {
+ AccessConditionModel condition = new HumanApprovalConditionModel();
+
+ var json = JsonSerializer.Serialize(condition, JsonOptions);
+
+ Assert.Equal("""{"kind":"human_approval"}""", json);
+ }
+
+ [Fact]
+ public void IpAllowlist_SerializesTo_KindAndCidrs()
+ {
+ AccessConditionModel condition = new IpAllowlistConditionModel
+ {
+ Cidrs = ["10.0.0.0/8"]
+ };
+
+ var json = JsonSerializer.Serialize(condition, JsonOptions);
+
+ Assert.Equal("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""", json);
+ }
+
+ // -------------------------------------------------------------------------
+ // Deserialization tests (the typed union, decoded in isolation)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Deserialize_UnknownKind_ThrowsJsonException()
+ {
+ // An unrecognized discriminator value is rejected fail-closed.
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(
+ """{"kind":"unknown_thing"}""", JsonOptions));
+ }
+
+ [Fact]
+ public void Deserialize_KindNotFirstProperty_Succeeds()
+ {
+ // serde's internally-tagged enums accept the tag at any position, so the server must too.
+ var condition = JsonSerializer.Deserialize(
+ """{"cidrs":["10.0.0.0/8"],"kind":"ip_allowlist"}""", JsonOptions);
+
+ var ipAllowlist = Assert.IsType(condition);
+ var cidr = Assert.Single(ipAllowlist.Cidrs);
+ Assert.Equal("10.0.0.0/8", cidr);
+ }
+
+ [Fact]
+ public void Deserialize_MissingKind_BindsToBaseType()
+ {
+ // Binding falls back to the concrete base instead of throwing; the base instance is then
+ // rejected by AccessRuleRequestModel.Validate (see MissingKindCondition_FailsValidation).
+ var condition = JsonSerializer.Deserialize("{}", JsonOptions);
+
+ Assert.NotNull(condition);
+ Assert.Equal(typeof(AccessConditionModel), condition.GetType());
+ }
+
+ [Theory]
+ // No 'kind' means the payload binds as the (member-less) base type, so every property is an
+ // unmapped member and the payload fails to decode.
+ [InlineData("""{"cidrs":["10.0.0.0/8"]}""")]
+ // The discriminator matches case-sensitively, mirroring the SDK's serde tag, so a PascalCase
+ // "Kind" is an unknown member, not a discriminator.
+ [InlineData("""{"Kind":"human_approval"}""")]
+ public void Deserialize_MissingKindWithMembers_ThrowsJsonException(string json)
+ {
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(json, JsonOptions));
+ }
+
+ [Theory]
+ [InlineData("""{"kind":null}""")]
+ [InlineData("""{"kind":42}""")]
+ public void Deserialize_NonStringKind_ThrowsJsonException(string json)
+ {
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(json, JsonOptions));
+ }
+
+ [Fact]
+ public void Deserialize_NonObjectCondition_ThrowsJsonException()
+ {
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(
+ "\"human_approval\"", JsonOptions));
+ }
+
+ [Theory]
+ // Unknown members are rejected fail-closed ([JsonUnmappedMemberHandling(Disallow)]): silently
+ // dropping an unrecognized constraint would enforce the condition more loosely than intended.
+ [InlineData("""{"kind":"human_approval","extra":"rejected"}""")]
+ [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"],"expires":"2026-08-01"}""")]
+ public void Deserialize_UnknownMember_ThrowsJsonException(string json)
+ {
+ Assert.Throws(() =>
+ JsonSerializer.Deserialize(json, JsonOptions));
+ }
+
+ // -------------------------------------------------------------------------
+ // Request validation: the "double decode" path
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmptyConditionsArray_PassesValidation()
+ {
+ var (isValid, results) = Validate(RequestWith());
+
+ Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage)));
+ }
+
+ [Fact]
+ public void MissingConditions_FailsValidation()
+ {
+ // A request that omits the conditions field entirely must fail [Required] — an omitted
+ // field and an explicitly empty array are distinct contract states.
+ var (isValid, results) = Validate(RequestWithoutConditions());
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions)));
+ }
+
+ [Fact]
+ public void NonArrayConditions_FailsValidation()
+ {
+ var (isValid, results) = Validate(RequestWithRawConditions("""{"kind":"human_approval"}"""));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions)));
+ }
+
+ [Fact]
+ public void MissingKindCondition_FailsValidation()
+ {
+ // A payload with no 'kind' binds to the concrete base type; validation must reject it.
+ var (isValid, results) = Validate(RequestWithRawConditions("[{}]"));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]"));
+ }
+
+ [Fact]
+ public void UnknownKindCondition_FailsValidation()
+ {
+ // The core fail-closed guarantee: a kind the server does not model is rejected, never
+ // silently accepted/dropped.
+ var (isValid, results) = Validate(RequestWithRawConditions("""[{"kind":"time_of_day"}]"""));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]"));
+ }
+
+ [Fact]
+ public void UnknownMemberCondition_FailsValidation()
+ {
+ var (isValid, results) = Validate(
+ RequestWithRawConditions("""[{"kind":"human_approval","extra":"rejected"}]"""));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]"));
+ }
+
+ [Fact]
+ public void KindNotFirstProperty_PassesValidation()
+ {
+ var (isValid, results) = Validate(
+ RequestWithRawConditions("""[{"cidrs":["10.0.0.0/8"],"kind":"ip_allowlist"}]"""));
+
+ Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage)));
+ }
+
+ [Fact]
+ public void NullConditionElement_FailsValidation()
+ {
+ var (isValid, results) = Validate(RequestWithRawConditions("[null]"));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]"));
+ }
+
+ [Fact]
+ public void NullCidrEntry_NestedInRequest_FailsValidation()
+ {
+ var (isValid, results) = Validate(RequestWith(
+ new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", null!] }));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]")));
+ }
+
+ [Fact]
+ public void InvalidCidrs_ReportIndexedMembers_WithoutEchoingValues()
+ {
+ var (isValid, results) = Validate(RequestWith(
+ new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", "bad-cidr/99", "10.0.0.1/8"] }));
+
+ Assert.False(isValid);
+ // Each failing entry gets its own indexed member key so clients can tell them apart...
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[1]"));
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[2]"));
+ Assert.DoesNotContain(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[0]"));
+ // ...and the raw user-supplied value never appears in the message.
+ Assert.DoesNotContain(results, r => r.ErrorMessage!.Contains("bad-cidr"));
+ }
+
+ [Fact]
+ public void OverlongCidrEntry_FailsValidation()
+ {
+ // A degenerate zero-padded prefix parses as a valid CIDR but must be blocked by the
+ // per-entry length cap before it can be persisted.
+ var overlong = "10.0.0.0/" + new string('0', 300) + "8";
+ var (isValid, results) = Validate(RequestWith(
+ new IpAllowlistConditionModel { Cidrs = [overlong] }));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[0]"));
+ }
+
+ [Fact]
+ public void ExactlyHundredCidrs_PassesValidation()
+ {
+ var cidrs = Enumerable.Range(1, 100).Select(i => $"10.{i}.0.0/16").ToList();
+ var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = cidrs }));
+
+ Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage)));
+ }
+
+ [Fact]
+ public void MoreThanHundredCidrs_FailsValidation()
+ {
+ var cidrs = Enumerable.Range(1, 101).Select(i => $"10.{i}.0.0/16").ToList();
+ var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = cidrs }));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]")));
+ }
+
+ [Fact]
+ public void MoreThanTenConditions_FailsValidation()
+ {
+ var conditions = Enumerable.Range(0, 11)
+ .Select(_ => (AccessConditionModel)new HumanApprovalConditionModel())
+ .ToArray();
+ var (isValid, results) = Validate(RequestWith(conditions));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions)));
+ }
+
+ [Fact]
+ public void ExactlyTenConditions_PassesValidation()
+ {
+ var conditions = Enumerable.Range(0, 10)
+ .Select(_ => (AccessConditionModel)new HumanApprovalConditionModel())
+ .ToArray();
+ var (isValid, results) = Validate(RequestWith(conditions));
+
+ Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage)));
+ }
+
+ [Fact]
+ public void EmptyCidrs_NestedInRequest_FailsValidation()
+ {
+ var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = [] }));
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]")));
+ }
+
+ [Fact]
+ public void InvalidCidr_NestedInRequest_FailsValidation()
+ {
+ var (isValid, results) = Validate(RequestWith(
+ new IpAllowlistConditionModel { Cidrs = ["10.0.0.1/8"] })); // host bits set
+
+ Assert.False(isValid);
+ Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]")));
+ }
+
+ [Fact]
+ public void ValidConditions_PassValidation()
+ {
+ var (isValid, results) = Validate(RequestWith(
+ new HumanApprovalConditionModel(),
+ new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", "2001:db8::/32"] }));
+
+ Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage)));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static (bool IsValid, List Results) Validate(AccessRuleRequestModel model)
+ {
+ var results = new List();
+ var isValid = Validator.TryValidateObject(
+ model, new ValidationContext(model), results, validateAllProperties: true);
+ return (isValid, results);
+ }
+
+ // Builds a request whose Conditions is the given typed conditions serialized to verbatim JSON,
+ // exercising the same serialize -> store -> decode path the wire contract uses.
+ private static AccessRuleRequestModel RequestWith(params AccessConditionModel[] conditions) =>
+ RequestWithRawConditions(JsonSerializer.Serialize(conditions, JsonOptions));
+
+ // Builds a request from a raw conditions JSON string, for shapes that can't be produced by
+ // serializing typed models (missing kind, null element, unknown members, non-array, ...).
+ private static AccessRuleRequestModel RequestWithRawConditions(string conditionsJson) =>
+ new()
+ {
+ Name = "Test rule",
+ Conditions = JsonSerializer.Deserialize(conditionsJson),
+ Collections = [Guid.NewGuid()],
+ };
+
+ private static AccessRuleRequestModel RequestWithoutConditions() =>
+ new()
+ {
+ Name = "Test rule",
+ Conditions = null,
+ Collections = [Guid.NewGuid()],
+ };
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs
new file mode 100644
index 000000000000..d4ee9fb55930
--- /dev/null
+++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs
@@ -0,0 +1,91 @@
+using Bit.Services.Pam.Api.Models.Request;
+using Xunit;
+
+namespace Bit.Services.Pam.Test.Api.Models;
+
+///
+/// Unit tests for . The test data mirrors the positive and
+/// negative tables from the Rust SDK's is_valid_cidr tests in
+/// bitwarden-pam/src/access_rules/validate.rs.
+///
+public class CidrValidatorTests
+{
+ // -------------------------------------------------------------------------
+ // Valid CIDRs
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("10.0.0.0/8")]
+ [InlineData("192.168.0.0/16")]
+ [InlineData("0.0.0.0/0")]
+ [InlineData("255.255.255.255/32")]
+ [InlineData("10.0.0.1/32")] // /32 single host — no host bits
+ [InlineData("::/0")]
+ [InlineData("::1/128")]
+ [InlineData("2001:db8::/32")]
+ [InlineData("fe80::/10")]
+ [InlineData("10.0.0.0/08")] // leading zero on prefix — valid (SDK: prefix_with_leading_zero_is_valid)
+ [InlineData("2001:db8::1/128")] // IPv6 single-host /128 — valid (SDK: ipv6_full_prefix_is_valid)
+ // IPv6 non-canonical-but-value-equivalent forms — valid (Rust Ipv6Addr::from_str compares by value)
+ [InlineData("2001:0db8::/32")] // leading zero in hextet
+ [InlineData("2001:DB8::/32")] // uppercase hextets
+ [InlineData("2001:db8:0:0:0:0:0:0/32")] // fully uncompressed form
+ public void IsValid_ValidCidrs_ReturnsTrue(string cidr)
+ {
+ Assert.True(CidrValidator.IsValid(cidr));
+ }
+
+ // -------------------------------------------------------------------------
+ // Invalid CIDRs
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ // Host bits set (IPv4)
+ [InlineData("10.0.0.1/8", "host bits set")]
+ [InlineData("10.0.0.0/0", "IPv4 /0 with non-zero address has host bits set")]
+ // Prefix out of range
+ [InlineData("10.0.0.0/33", "IPv4 prefix > 32")]
+ [InlineData("2001:db8::/129", "IPv6 prefix > 128")]
+ [InlineData("2001:db8::/300", "IPv6 prefix > 255 — byte.TryParse fails")]
+ // Non-digit or signed prefix characters
+ [InlineData("10.0.0.0/-1", "negative prefix")]
+ [InlineData("10.0.0.0/+8", "signed positive prefix")]
+ [InlineData("10.0.0.0/ 8", "space in prefix")]
+ // Non-canonical address forms
+ [InlineData("010.0.0.0/8", "leading zero in octet")]
+ [InlineData("0x0A.0.0.0/8", "hex octet")]
+ [InlineData("10.0/8", "partial IPv4 address")]
+ [InlineData("1.2.3/24", "3-part IPv4 address")]
+ // Structural problems
+ [InlineData("not-an-ip/8", "garbage address")]
+ [InlineData("/8", "empty address")]
+ [InlineData("10.0.0.0/", "empty prefix")]
+ [InlineData("10.0.0.0", "no slash")]
+ [InlineData("10.0.0.0/8/8", "double slash — prefix part '8/8' contains non-digit")]
+ // Zone IDs
+ [InlineData("fe80::1%eth0/64", "IPv6 zone ID")]
+ [InlineData("fe80::1%1/64", "IPv6 zone ID (numeric)")]
+ // Bracketed IPv6 forms — Rust rejects these; server must match
+ [InlineData("[::1]/128", "bracketed IPv6 — Rust rejects bracketed form")]
+ [InlineData("[2001:db8::]/32", "bracketed IPv6 prefix")]
+ // Whitespace
+ [InlineData(" 10.0.0.0/8", "leading whitespace")]
+ // Host bits set (IPv6)
+ [InlineData("2001:db8::1/32", "IPv6 host bits set")]
+ // IPv4-mapped IPv6 — SDK rejects these (test ipv4_mapped_ipv6_is_invalid); the mapped form is
+ // ambiguous with the native IPv4 range
+ [InlineData("::ffff:10.0.0.0/104", "IPv4-mapped IPv6")]
+ [InlineData("::ffff:1.2.3.4/128", "IPv4-mapped IPv6 single host")]
+ public void IsValid_InvalidCidrs_ReturnsFalse(string cidr, string reason)
+ {
+ // reason is only for readability in the test output
+ Assert.False(CidrValidator.IsValid(cidr), $"Expected '{cidr}' to be invalid ({reason})");
+ }
+
+ [Fact]
+ public void IsValid_Null_ReturnsFalse()
+ {
+ // JSON binding can produce null list entries; the validator must not throw on them.
+ Assert.False(CidrValidator.IsValid(null));
+ }
+}
diff --git a/bitwarden_license/test/Services/Pam.Test/packages.lock.json b/bitwarden_license/test/Services/Pam.Test/packages.lock.json
index 603b17d76ff9..731184dc6469 100644
--- a/bitwarden_license/test/Services/Pam.Test/packages.lock.json
+++ b/bitwarden_license/test/Services/Pam.Test/packages.lock.json
@@ -1296,6 +1296,13 @@
},
"httpextensions": {
"type": "Project"
+ },
+ "pam": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.2, )",
+ "HttpExtensions": "[2026.6.2, )"
+ }
}
}
}