Skip to content
Draft
89 changes: 89 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
namespace Bit.Services.Pam.Engine;

/// <summary>
/// The result of evaluating a single access condition, or the combined result of a rule's whole condition list.
/// </summary>
public enum AccessEvaluationOutcome
{
/// <summary>Access is granted automatically, with no human decision required.</summary>
Allow,

/// <summary>A human decision is required before a lease may be issued.</summary>
RequiresApproval,

/// <summary>Access is refused; the accompanying <see cref="DenyReason"/> records why.</summary>
Deny,
}

/// <summary>
/// Why an evaluation denied. Carried on a deny <see cref="AccessEvaluation"/>; <see cref="None"/> is the default
/// for any non-deny outcome.
/// </summary>
public enum DenyReason
{
/// <summary>Not a denial (the outcome is allow or requires-approval).</summary>
None = 0,

/// <summary>The caller's IP was absent, the allowlist was empty, or the IP fell outside every listed CIDR.</summary>
NotWithinIpRange,

/// <summary>The timezone was unknown/invalid, or the instant fell outside every configured window.</summary>
NotWithinTimeWindow,

/// <summary>
/// A condition entry could not be evaluated — in practice a null entry from a malformed stored document — so it
/// fails closed. A genuinely unknown <c>kind</c> cannot reach here: the JSON layer rejects unknown kinds and the
/// visitor dispatch is exhaustive at compile time.
/// </summary>
UnsupportedCondition,
}

/// <summary>
/// The outcome of evaluating an access condition (or a combined rule result): an <see cref="Outcome"/> plus, when
/// it is a denial, the <see cref="Reason"/>. Build instances via <see cref="Allow"/>, <see cref="RequiresApproval"/>,
/// or <see cref="Deny"/>, and fold a sequence together with <see cref="Combine"/>.
/// </summary>
public sealed record AccessEvaluation
{
/// <summary>The evaluation's verdict.</summary>
public required AccessEvaluationOutcome Outcome { get; init; }

/// <summary>Why access was denied; <see cref="DenyReason.None"/> unless <see cref="Outcome"/> is <see cref="AccessEvaluationOutcome.Deny"/>.</summary>
public DenyReason Reason { get; init; } = DenyReason.None;

/// <summary>A shared allow result.</summary>
public static AccessEvaluation Allow { get; } = new() { Outcome = AccessEvaluationOutcome.Allow };

/// <summary>A shared requires-approval result.</summary>
public static AccessEvaluation RequiresApproval { get; } = new() { Outcome = AccessEvaluationOutcome.RequiresApproval };

/// <summary>Builds a deny result carrying the given <paramref name="reason"/>.</summary>
public static AccessEvaluation Deny(DenyReason reason) => new()
{
Outcome = AccessEvaluationOutcome.Deny,
Reason = reason
};

/// <summary>
/// Folds a sequence of per-condition evaluations into one, with <b>deny &gt; requires-approval &gt; allow</b>
/// precedence: the first deny short-circuits and is returned as-is; otherwise any requires-approval wins over
/// allow. An empty sequence is vacuously satisfied and returns <see cref="Allow"/>.
/// </summary>
public static AccessEvaluation Combine(IEnumerable<AccessEvaluation> evaluations)
{
var requiresApproval = false;
foreach (var evaluation in evaluations)
{
switch (evaluation.Outcome)
{
case AccessEvaluationOutcome.Deny:
return evaluation;
case AccessEvaluationOutcome.RequiresApproval:
requiresApproval = true;
break;
}
}

return requiresApproval ? RequiresApproval : Allow;
}
}
19 changes: 19 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/AccessRuleEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Engine;

/// <summary>
/// Combines the results of an access rule's flat list of <see cref="AccessCondition"/>s into one decision. Each
/// condition evaluates itself (<see cref="AccessCondition.Evaluate"/>); the engine only folds those results, with
/// deny taking precedence over a pending approval, which in turn takes precedence over allow. An empty list is
/// vacuously satisfied (allow). Unparseable inputs fail closed before they reach the engine.
/// </summary>
public sealed class AccessRuleEngine : IAccessRuleEngine
{
public AccessEvaluation Evaluate(IReadOnlyList<AccessCondition> conditions, AccessSignals signals) =>
AccessEvaluation.Combine(conditions.Select(condition => EvaluateOne(condition, signals)));

private static AccessEvaluation EvaluateOne(AccessCondition? condition, AccessSignals signals) =>
// A null entry (malformed stored conditions) cannot be evaluated, so fail closed.
condition is null ? AccessEvaluation.Deny(DenyReason.UnsupportedCondition) : condition.Evaluate(signals);
}
25 changes: 25 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/AccessSignals.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Net;

namespace Bit.Services.Pam.Engine;

/// <summary>
/// The request-time inputs an access rule is evaluated against: the caller's source IP and the instant the
/// evaluation is performed. <see cref="IpAddress"/> is null when the caller's address cannot be determined, which
/// IP-restricted rules treat as a denial so access never opens up on a missing signal.
/// </summary>
public sealed record AccessSignals
{
public required IPAddress? IpAddress { get; init; }
public required DateTimeOffset Timestamp { get; init; }

/// <summary>
/// Builds the signals for the current request: the caller's source IP (parsed, or null when it is absent or
/// unparseable) and the supplied evaluation <paramref name="timestamp"/>. Callers typically pass the request's
/// source address (e.g. <c>ICurrentContext.IpAddress</c>).
/// </summary>
public static AccessSignals From(string? ipAddress, DateTimeOffset timestamp) => new()
{
IpAddress = IPAddress.TryParse(ipAddress, out var ip) ? ip : null,
Timestamp = timestamp,
};
}
14 changes: 14 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/IAccessRuleEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Engine;

/// <summary>
/// Evaluates an access rule's conditions — a flat list of <see cref="AccessCondition"/> ANDed together — against
/// the request-time <see cref="AccessSignals"/>, deciding whether access is allowed, denied, or gated on human
/// approval. The engine is pure: it reads no state and issues no leases. Lease lifecycle is owned by the lease
/// commands and queries, which call the engine to decide whether a lease may be issued or its data handed over.
/// </summary>
public interface IAccessRuleEngine
{
AccessEvaluation Evaluate(IReadOnlyList<AccessCondition> conditions, AccessSignals signals);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using Bit.Services.Pam.Engine;

namespace Bit.Services.Pam.Models.Conditions;

Expand All @@ -10,4 +11,18 @@ namespace Bit.Services.Pam.Models.Conditions;
[JsonDerivedType(typeof(HumanApprovalCondition), "human_approval")]
[JsonDerivedType(typeof(IpAllowlistCondition), "ip_allowlist")]
[JsonDerivedType(typeof(TimeOfDayCondition), "time_of_day")]
public abstract class AccessCondition;
public abstract class AccessCondition
{
/// <summary>
/// Evaluates this condition against the request-time <paramref name="signals"/>, returning whether it allows,
/// denies, or requires human approval. The engine folds each condition's result into the rule's overall
/// decision; it does not know how any individual kind decides.
/// </summary>
public abstract AccessEvaluation Evaluate(AccessSignals signals);

/// <summary>
/// Checks this condition is well-formed at write time, before it is persisted, returning an actionable error
/// when it is not. The loud, reject-on-save counterpart to <see cref="Evaluate"/>'s fail-closed runtime behavior.
/// </summary>
public abstract AccessRuleValidationResult Validate();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json;

namespace Bit.Services.Pam.Models.Conditions;

/// <summary>
/// The single source of truth for how an access rule's conditions JSON is (de)serialized: camelCase property
/// names, read case-insensitively. Everything that parses the stored <c>Conditions</c> document — the validator
/// at write time and the resolver at read time — must use <see cref="Options"/> so the two never drift.
/// The accepted <c>kind</c> vocabulary itself lives on <see cref="AccessCondition"/>'s <c>[JsonDerivedType]</c>
/// attributes.
/// </summary>
public static class AccessConditionJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Bit.Services.Pam.Models.Conditions;

/// <summary>
/// The result of checking a condition (or a rule's whole conditions document) is well-formed: whether it is valid
/// and, when not, an actionable message. Produced at write time by <see cref="AccessCondition.Validate"/> and by
/// <see cref="Bit.Services.Pam.Services.IAccessRuleValidator"/>.
/// </summary>
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);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
namespace Bit.Services.Pam.Models.Conditions;
using Bit.Services.Pam.Engine;

namespace Bit.Services.Pam.Models.Conditions;

/// <summary>
/// Always requires a human decision before a lease can be issued.
/// </summary>
public sealed class HumanApprovalCondition : AccessCondition;
/// <remarks>Wire format: <c>{ "kind": "human_approval" }</c></remarks>
public sealed class HumanApprovalCondition : AccessCondition
{
public override AccessEvaluation Evaluate(AccessSignals signals) => AccessEvaluation.RequiresApproval;

public override AccessRuleValidationResult Validate() => AccessRuleValidationResult.Valid;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,59 @@
namespace Bit.Services.Pam.Models.Conditions;
using System.Net;
using Bit.Services.Pam.Engine;

namespace Bit.Services.Pam.Models.Conditions;

/// <summary>
/// Auto-approves a lease when the requester's IP matches a listed CIDR; otherwise denies.
/// </summary>
/// <remarks>
/// Wire format:
/// <code>
/// { "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8", "2001:db8::/32"] }
/// </code>
/// </remarks>
public sealed class IpAllowlistCondition : AccessCondition
{
/// <summary>
/// The allowed source ranges in CIDR notation (e.g. <c>"10.0.0.0/8"</c>). The condition allows when the caller's
/// IP is in any one of them. At least one required, and each must parse; an empty list denies.
/// </summary>
public IReadOnlyList<string> Cidrs { get; init; } = [];

public override AccessEvaluation Evaluate(AccessSignals signals)
{
// An allowlist with no entries permits no address; combined with an unknown caller IP, both fail closed.
if (Cidrs.Count == 0 || signals.IpAddress is null)
{
return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
}

foreach (var cidr in Cidrs)
{
if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress))
{
return AccessEvaluation.Allow;
}
}
Comment on lines +31 to +37

return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
}

public override AccessRuleValidationResult Validate()
{
if (Cidrs.Count == 0)
{
return AccessRuleValidationResult.Invalid("ip_allowlist requires at least one CIDR.");
}

foreach (var cidr in Cidrs)
{
if (string.IsNullOrWhiteSpace(cidr) || !IPNetwork.TryParse(cidr, out _))
{
return AccessRuleValidationResult.Invalid($"Invalid CIDR: '{cidr}'.");
}
}
Comment on lines +49 to +55

return AccessRuleValidationResult.Valid;
}
}
Loading
Loading