Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/AccessEvaluation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Bit.Services.Pam.Engine;

public enum AccessEvaluationOutcome
{
Allow,
RequiresApproval,
Deny,
}

public enum DenyReason
{
None = 0,
NotWithinIpRange,
NotWithinTimeWindow,
UnsupportedCondition,
}

public sealed record AccessEvaluation
{
public required AccessEvaluationOutcome Outcome { get; init; }
public DenyReason Reason { get; init; } = DenyReason.None;

public static AccessEvaluation Allow { get; } = new() { Outcome = AccessEvaluationOutcome.Allow };
public static AccessEvaluation RequiresApproval { get; } = new() { Outcome = AccessEvaluationOutcome.RequiresApproval };

public static AccessEvaluation Deny(DenyReason reason) => new()
{
Outcome = AccessEvaluationOutcome.Deny,
Reason = reason
};

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;
}
}
82 changes: 82 additions & 0 deletions bitwarden_license/src/Services/Pam/Engine/AccessRuleEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Globalization;
using System.Net;
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Engine;

/// <summary>
/// Evaluates the access rule's flat list of <see cref="AccessCondition"/>s against the caller's signals. Each
/// condition yields an <see cref="AccessEvaluation"/>; the results combine 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 => EvaluateCondition(condition, signals)));

private static AccessEvaluation EvaluateCondition(AccessCondition condition, AccessSignals signals) => condition switch
{
HumanApprovalCondition => AccessEvaluation.RequiresApproval,
IpAllowlistCondition ip => EvaluateIpAllowlist(ip, signals),
TimeOfDayCondition time => EvaluateTimeOfDay(time, signals),
// A condition kind the engine does not understand cannot be shown to be satisfied, so deny.
_ => AccessEvaluation.Deny(DenyReason.UnsupportedCondition),
};

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

foreach (var cidr in condition.Cidrs)
{
if (IPNetwork.TryParse(cidr, out var network) && network.Contains(signals.IpAddress))
{
return AccessEvaluation.Allow;
}
}

return AccessEvaluation.Deny(DenyReason.NotWithinIpRange);
}

private static AccessEvaluation EvaluateTimeOfDay(TimeOfDayCondition condition, AccessSignals signals)
{
if (!TimeZoneInfo.TryFindSystemTimeZoneById(condition.Tz, out var timeZone))
{
// The window cannot be evaluated without a valid timezone, so fail closed.
return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
}

var local = TimeZoneInfo.ConvertTime(signals.Timestamp, timeZone);
var day = local.DayOfWeek;
var time = TimeOnly.FromTimeSpan(local.TimeOfDay);

foreach (var window in condition.Windows)
{
if (WindowContains(window, day, time))
{
return AccessEvaluation.Allow;
}
}

return AccessEvaluation.Deny(DenyReason.NotWithinTimeWindow);
}

private static bool WindowContains(TimeWindow window, DayOfWeek day, TimeOnly time)
{
// AccessWeekday values align with System.DayOfWeek (Sunday = 0), so a direct cast compares correctly.
var dayMatches = window.Days.Any(d => (DayOfWeek)d == day);
if (!dayMatches)
{
return false;
}

return TimeOnly.TryParseExact(window.From, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from)
&& TimeOnly.TryParseExact(window.To, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to)
&& time >= from && time <= to;
}
}
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);
}
36 changes: 36 additions & 0 deletions bitwarden_license/src/Services/Pam/Models/GoverningRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Models;

/// <summary>
/// The access rule that governs a cipher for a particular caller: which collection's rule applies, the owning
/// organization, whether the rule requires human approval, and the parsed flat list of <see cref="AccessCondition"/>s
/// so the rule engine can evaluate them against the caller's signals. A null governing rule means the cipher is not
/// leasing-gated for the caller.
/// </summary>
public sealed record GoverningRule(
Guid OrganizationId,
Guid CollectionId,
bool RequiresHumanApproval,
IReadOnlyList<AccessCondition> Conditions)
{
/// <summary>
/// The identity of the resolved access rule. Resolution is deterministic (oldest rule wins; see
/// <see cref="Services.IGoverningRuleResolver"/>), so this is the rule that a request should pin at submit once
/// pinning is persisted. Until then it is re-resolved on every operation and can drift if the governing rules
/// change between submit and a later read.
/// </summary>
public Guid RuleId { get; init; }

/// <summary>
/// When true, a member holding an active lease under this rule may extend it once (always auto-approved), by up
/// to <see cref="MaxExtensionDurationSeconds"/>.
/// </summary>
public bool AllowsExtensions { get; init; }

/// <summary>
/// The longest a single extension under this rule may run, in seconds; meaningful only when
/// <see cref="AllowsExtensions"/> is true.
/// </summary>
public int? MaxExtensionDurationSeconds { get; init; }
}
114 changes: 114 additions & 0 deletions bitwarden_license/src/Services/Pam/Services/GoverningRuleResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Text.Json;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Pam.Entities;
using Bit.Pam.Repositories;
using Bit.Services.Pam.Engine;
using Bit.Services.Pam.Models;
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Services;

public class GoverningRuleResolver : IGoverningRuleResolver
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};

private readonly ICollectionCipherRepository _collectionCipherRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IAccessRuleRepository _accessRuleRepository;
private readonly IAccessRuleEngine _ruleEngine;

public GoverningRuleResolver(
ICollectionCipherRepository collectionCipherRepository,
ICollectionRepository collectionRepository,
IAccessRuleRepository accessRuleRepository,
IAccessRuleEngine ruleEngine)
{
_collectionCipherRepository = collectionCipherRepository;
_collectionRepository = collectionRepository;
_accessRuleRepository = accessRuleRepository;
_ruleEngine = ruleEngine;
}

public async Task<GoverningRule?> ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals)
{
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, cipherId);
if (collectionCiphers.Count == 0)
{
return null;
}

var collectionIds = collectionCiphers.Select(cc => cc.CollectionId).ToHashSet();
var collections = await _collectionRepository.GetManyByManyIdsAsync(collectionIds);

var governedCollections = collections
.Where(c => collectionIds.Contains(c.Id) && c.AccessRuleId.HasValue);

// Load every rule on the collections through which the caller reaches the cipher, keeping each paired with
// the collection it gates. A rule that no longer loads (deleted after the collection was read — deletes clear
// the link, so this is only a race) is skipped, so a deleted rule stops governing.
var candidates = new List<(Collection Collection, AccessRule Rule)>();
foreach (var collection in governedCollections)
{
var accessRule = await _accessRuleRepository.GetByIdAsync(collection.AccessRuleId!.Value);
if (accessRule is not null)
{
candidates.Add((collection, accessRule));
}
}

if (candidates.Count == 0)
{
return null;
}

// Oldest wins: the rule with the earliest CreationDate governs, ties broken on rule id so the choice is total
// and stable. Selection is purely structural — it does NOT depend on how a rule's conditions evaluate for the
// current signals — so a newer path never pre-empts an older one, whichever is the more permissive. This is a
// deliberate trade of determinism over least-restriction: a member may be routed to an approver even though a
// newer path would have auto-granted, because the older rule governs. The chosen rule's conditions are then
// evaluated below only to decide whether it routes to a human or resolves automatically.
var (governingCollection, governingRule) = candidates
.OrderBy(c => c.Rule.CreationDate)
.ThenBy(c => c.Rule.Id)
.First();

var conditions = Parse(governingRule.Conditions);
var outcome = _ruleEngine.Evaluate(conditions, signals).Outcome;

return new GoverningRule(
governingCollection.OrganizationId,
governingCollection.Id,
outcome == AccessEvaluationOutcome.RequiresApproval,
conditions)
{
RuleId = governingRule.Id,
AllowsExtensions = governingRule.AllowsExtensions,
MaxExtensionDurationSeconds = governingRule.MaxExtensionDurationSeconds,
};
}

/// <summary>
/// Parses the stored conditions JSON into a flat list of <see cref="AccessCondition"/>. A malformed or
/// unparseable document fails safe to a single human-approval condition so access is never silently auto-approved
/// on conditions the server could not understand; the human-approval path then routes it to an approver rather
/// than issuing an automatic lease.
/// </summary>
private static IReadOnlyList<AccessCondition> Parse(string conditionsJson)
{
try
{
return JsonSerializer.Deserialize<List<AccessCondition>>(conditionsJson, _jsonOptions) ?? FailSafe();
}
catch (JsonException)
{
return FailSafe();
}
}

private static IReadOnlyList<AccessCondition> FailSafe() => [new HumanApprovalCondition()];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Bit.Services.Pam.Engine;
using Bit.Services.Pam.Models;

namespace Bit.Services.Pam.Services;

public interface IGoverningRuleResolver
{
/// <summary>
/// Resolves the access rule that governs <paramref name="cipherId"/> for the caller, or null when the cipher is
/// not leasing-gated for them (no reachable collection carries an access rule). When more than one governing
/// collection applies, the oldest rule wins — the one with the earliest creation date, ties broken on rule id so
/// the choice is total and stable. Selection is purely structural and does NOT depend on how a rule's conditions
/// evaluate for the current <paramref name="signals"/>: a newer path never pre-empts an older one, whichever is
/// the more permissive, so a caller may be routed to an approver even though a newer path would have auto-granted.
/// The resolved rule's conditions are still evaluated against <paramref name="signals"/> to report whether it
/// requires human approval.
/// </summary>
Task<GoverningRule?> ResolveAsync(Guid userId, Guid cipherId, AccessSignals signals);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Endpoints;
using Bit.Services.Pam.Api.Endpoints.Handlers;
using Bit.Services.Pam.Engine;
using Bit.Services.Pam.OrganizationFeatures.Commands;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.Services;
Expand All @@ -17,6 +18,12 @@ public static IServiceCollection AddPamServices(this IServiceCollection services
services.AddScoped<AccessRequestEndpointsHandler>();
services.AddScoped<AccessRuleEndpointsHandler>();

// Rule evaluation engine. Pure and stateless, so a singleton is safe.
services.AddSingleton<IAccessRuleEngine, AccessRuleEngine>();

// Resolves the access rule governing a cipher for a caller, then evaluates it via the engine.
services.AddScoped<IGoverningRuleResolver, GoverningRuleResolver>();

// AccessRule write path.
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IAccessRuleValidator, AccessRuleValidator>();
Expand Down
Loading
Loading