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
Original file line number Diff line number Diff line change
@@ -1,32 +1,86 @@
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;

/// <summary>
/// Handler for the <c>organizations/{orgId}/access-rules</c> resource. The Minimal API endpoints (see
/// <c>AccessRuleEndpoints</c>) resolve this handler from DI.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class AccessRuleEndpointsHandler
public class AccessRuleEndpointsHandler(
ICurrentContext currentContext,
IAccessRuleRepository repository,
ICreateAccessRuleCommand createCommand,
IUpdateAccessRuleCommand updateCommand,
IDeleteAccessRuleCommand deleteCommand)
{
public Task<ListResponseModel<AccessRuleResponseModel>> GetAll(Guid orgId)
=> throw new NotImplementedException();
public async Task<ListResponseModel<AccessRuleResponseModel>> GetAll(Guid orgId)
{
await EnsureMemberAsync(orgId);

public Task<AccessRuleResponseModel> Get(Guid orgId, Guid id)
=> throw new NotImplementedException();
var rules = await repository.GetManyDetailsByOrganizationIdAsync(orgId);
return new ListResponseModel<AccessRuleResponseModel>(
rules.Select(rule => new AccessRuleResponseModel(rule)));
}

public Task<AccessRuleResponseModel> Post(Guid orgId, AccessRuleRequestModel model)
=> throw new NotImplementedException();
public async Task<AccessRuleResponseModel> Get(Guid orgId, Guid id)
{
await EnsureMemberAsync(orgId);

public Task<AccessRuleResponseModel> 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<AccessRuleResponseModel> 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<AccessRuleResponseModel> 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Pam.Entities;

namespace Bit.Services.Pam.Api.Models.Request;

Expand All @@ -23,9 +25,10 @@ public class AccessRuleRequestModel
public bool Enabled { get; set; } = true;

/// <summary>
/// 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.
/// </summary>
[Required]
public object Conditions { get; set; } = null!;
Expand Down Expand Up @@ -65,4 +68,24 @@ public class AccessRuleRequestModel
/// </summary>
[Required]
public IEnumerable<Guid> 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),
};
}
Original file line number Diff line number Diff line change
@@ -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();
}

/// <summary>
/// The rule's unique identifier.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; }

/// <summary>
/// The organization this rule belongs to.
/// </summary>
public Guid OrganizationId { get; set; }
public Guid OrganizationId { get; }

/// <summary>
/// The rule's display name, shown wherever rules are listed and managed.
/// </summary>
public string Name { get; set; } = null!;
public string Name { get; }

/// <summary>
/// Optional free-text describing the rule's intent. Has no effect on evaluation; surfaced to admins only.
/// </summary>
public string? Description { get; set; }
public string? Description { get; }

/// <summary>
/// When false, the rule is inactive and does not gate access for the collections it governs.
/// </summary>
public bool Enabled { get; set; }
public bool Enabled { get; }

/// <summary>
/// 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.
/// </summary>
public JsonElement? Conditions { get; set; }
public JsonElement? Conditions { get; }

/// <summary>
/// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users).
/// </summary>
public bool SingleActiveLease { get; set; }
public bool SingleActiveLease { get; }

/// <summary>
/// Default lease duration in seconds, used to pre-fill a request opened under this rule. Null means the
/// backend default applies.
/// </summary>
public int? DefaultLeaseDurationSeconds { get; set; }
public int? DefaultLeaseDurationSeconds { get; }

/// <summary>
/// Hard ceiling on the duration of any single lease granted under this rule, in seconds. Null means no
/// per-rule cap.
/// </summary>
public int? MaxLeaseDurationSeconds { get; set; }
public int? MaxLeaseDurationSeconds { get; }

/// <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; set; }
public bool AllowsExtensions { get; }

/// <summary>
/// The longest a single extension may run, in seconds. Set when <see cref="AllowsExtensions"/> is true.
/// </summary>
public int? MaxExtensionDurationSeconds { get; set; }
public int? MaxExtensionDurationSeconds { get; }

/// <summary>
/// The complete set of collections this rule governs.
/// </summary>
public IEnumerable<Guid> Collections { get; set; } = null!;
public IEnumerable<Guid> Collections { get; }

/// <summary>
/// When the rule was created (UTC).
/// </summary>
public DateTime CreationDate { get; set; }
public DateTime CreationDate { get; }

/// <summary>
/// When the rule was last modified (UTC).
/// </summary>
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Bit.Services.Pam.Api.Models.Response;

/// <summary>
/// Marks PAM response timestamps as UTC for serialization.
///
/// PAM entities and read models are materialised by Dapper, which leaves their <see cref="DateTime.Kind"/> as
/// <see cref="DateTimeKind.Unspecified"/>. System.Text.Json then writes an unspecified-kind value with no timezone
/// designator (e.g. <c>"2026-06-15T13:00:00"</c>), which a JavaScript client parses as <em>local</em> 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 <c>UtcNow</c>), so we relabel the kind
/// with <see cref="DateTime.SpecifyKind"/>. We deliberately do not use <c>ToUniversalTime()</c>, which treats an
/// unspecified value as local and would shift the clock. This mirrors the convention in <c>CipherRepository</c>, which
/// specifies UTC on the dates it returns.
/// </summary>
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;
}
21 changes: 21 additions & 0 deletions bitwarden_license/src/Services/Pam/Enums/AccessWeekday.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using Bit.Services.Pam.Models.Conditions;

namespace Bit.Services.Pam.Enums;

/// <summary>
/// A day of the week used in a <see cref="Models.Conditions.TimeOfDayCondition"/> window. Values align with
/// <see cref="System.DayOfWeek"/> (Sunday = 0) so the engine can compare directly. Serialized as the lowercase
/// three-letter tokens (<c>"sun".."sat"</c>) via <see cref="AccessWeekdayJsonConverter"/>.
/// </summary>
[JsonConverter(typeof(AccessWeekdayJsonConverter))]
public enum AccessWeekday : byte
{
Sun = 0,
Mon = 1,
Tue = 2,
Wed = 3,
Thu = 4,
Fri = 5,
Sat = 6,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;

namespace Bit.Services.Pam.Models.Conditions;

/// <summary>
/// Base type for a single leaf condition in an access rule's flat conditions list. Polymorphic deserialization is
/// keyed by the JSON <c>kind</c> property.
/// </summary>
[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;
Loading
Loading