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
Expand Up @@ -10,13 +10,19 @@ namespace Bit.Services.Pam.Api.Endpoints.Filters;
/// </summary>
public class PamValidationEndpointFilter : IEndpointFilter
{
private const string RequestModelNamespace = "Bit.Services.Pam.Api.Models.Request";
// A prefix/suffix match rather than an exact one, so nested feature subtrees that mirror the same
// Api/Models/Request folder convention -- e.g. Rotation's Bit.Services.Pam.Rotation.Api.Models.Request --
// are covered without this filter needing to know about every subtree by name.
private const string RequestModelNamespacePrefix = "Bit.Services.Pam.";
private const string RequestModelNamespaceSuffix = ".Api.Models.Request";

public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
foreach (var argument in context.Arguments)
{
if (argument is null || argument.GetType().Namespace != RequestModelNamespace)
if (argument is null || argument.GetType().Namespace is not { } ns
|| !ns.StartsWith(RequestModelNamespacePrefix, StringComparison.Ordinal)
|| !ns.EndsWith(RequestModelNamespaceSuffix, StringComparison.Ordinal))
{
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Bit.Core.Auth.Identity;
using Bit.Core.Models.Api;
using Bit.Services.Pam.Api.Endpoints.Filters;
using Bit.Services.Pam.Rotation.Api.Endpoints;
using Bit.Services.Pam.Rotation.Api.Endpoints.Filters;

namespace Bit.Services.Pam.Api.Endpoints;

Expand All @@ -19,18 +21,60 @@ public static void MapPamEndpoints(this IEndpointRouteBuilder endpoints)
endpoints.MapGroup("/access-requests").WithPamDefaults().MapAccessRequestEndpoints();
endpoints.MapGroup("/organizations/{orgId:guid}/access-rules").WithPamDefaults().MapAccessRuleEndpoints();
endpoints.MapGroup("/leases/ciphers/{id:guid}").WithPamDefaults().MapCipherLeaseEndpoints();

// Credential rotation -- admin fleet/config management, gated behind the PamRotation flag on top of the
// same org-scoped Policies.Application every other admin group uses.
endpoints.MapGroup("/organizations/{orgId:guid}/rotation/daemons").WithPamRotationDefaults().MapRotationDaemonEndpoints();
endpoints.MapGroup("/organizations/{orgId:guid}/rotation/target-systems").WithPamRotationDefaults().MapRotationTargetSystemEndpoints();
endpoints.MapGroup("/organizations/{orgId:guid}/rotation/configs").WithPamRotationDefaults().MapRotationConfigEndpoints();

// Credential rotation -- the daemon-facing surface. Policies.PamRotationDaemon replaces Policies.Application
// (a machine-credential bearer token, not a user's), and DaemonRequestEndpointFilter re-verifies the daemon
// end to end on every request and bumps its heartbeat.
endpoints.MapGroup("/rotation/daemon").WithPamDaemonDefaults().MapRotationDaemonJobsEndpoints();
endpoints.MapGroup("/rotation/jobs").WithPamDaemonDefaults().MapRotationJobEndpoints();
endpoints.MapGroup("/rotation/attempts").WithPamDaemonDefaults().MapRotationAttemptEndpoints();
}

/// <summary>Applies the shared PAM endpoint chain with the surface's usual authorization policy and feature flag.</summary>
private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group) =>
group.WithPamDefaults(Policies.Application, FeatureFlagKeys.Pam);

/// <summary>
/// Rotation's admin surface: org admin/owner-gated (per-row org checks happen in the commands), behind the
/// rotation flag rather than the base PAM flag.
/// </summary>
private static RouteGroupBuilder WithPamRotationDefaults(this RouteGroupBuilder group) =>
group.WithPamDefaults(Policies.Application, FeatureFlagKeys.PamRotation)
.WithConflictResponseMetadata();

/// <summary>
/// Rotation's daemon-facing surface: <see cref="Policies.PamRotationDaemon"/> instead of the user-token
/// <see cref="Policies.Application"/>, plus <see cref="DaemonRequestEndpointFilter"/> on every route to
/// re-verify the daemon and bump its heartbeat. Runs after the feature/validation filters so a disabled flag or
/// an invalid body short-circuits before the extra daemon lookup.
///
/// TODO(PM-39040): rate-limit this group by client_id -- no endpoint-group rate-limit primitive exists
/// elsewhere in this codebase yet to reuse, so this is deliberately left as a follow-up rather than a bespoke
/// mechanism invented for rotation alone.
/// </summary>
private static RouteGroupBuilder WithPamDaemonDefaults(this RouteGroupBuilder group) =>
group.WithPamDefaults(Policies.PamRotationDaemon, FeatureFlagKeys.PamRotation)
.WithConflictResponseMetadata()
.AddEndpointFilter<DaemonRequestEndpointFilter>();

/// <summary>
/// Applies the shared PAM endpoint chain to a group. Order matters: the exception filter is outermost so it
/// translates throws from the feature filter (<see cref="Bit.Core.Exceptions.FeatureUnavailableException"/>),
/// the validation filter, and the handlers into the <c>ErrorResponseModel</c> contract.
/// Applies the shared PAM endpoint chain to a group for the given authorization policy and feature flag. Order
/// matters: the exception filter is outermost so it translates throws from the feature filter
/// (<see cref="Bit.Core.Exceptions.FeatureUnavailableException"/>), the validation filter, and the handlers into
/// the <c>ErrorResponseModel</c> contract. The zero-argument <see cref="WithPamDefaults(RouteGroupBuilder)"/>
/// overload delegates here with the original policy/flag, so every pre-existing group is unaffected.
/// </summary>
private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group)
private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group, string policy, string featureFlagKey)
{
group.RequireAuthorization(Policies.Application);
group.RequireAuthorization(policy);
group.AddEndpointFilter<PamExceptionHandlerEndpointFilter>();
group.RequireFeature(FeatureFlagKeys.Pam);
group.RequireFeature(featureFlagKey);
group.AddEndpointFilter<PamValidationEndpointFilter>();
group.WithGroupName("internal");

Expand All @@ -43,6 +87,18 @@ private static RouteGroupBuilder WithPamDefaults(this RouteGroupBuilder group)
return group;
}

/// <summary>
/// Adds the 409 Conflict case to a group's documented responses -- rotation is the first PAM surface where
/// commands throw <see cref="Bit.Core.Exceptions.ConflictException"/> in the ordinary course of business (lost
/// claim races, stale reports, concurrent cipher writes), so the pre-existing groups' metadata is left as-is.
/// </summary>
private static RouteGroupBuilder WithConflictResponseMetadata(this RouteGroupBuilder group)
{
group.WithMetadata(
new ProducesResponseTypeMetadata(StatusCodes.Status409Conflict, typeof(ErrorResponseModel), ["application/json"]));
return group;
}

/// <summary>
/// Minimal API equivalent of <c>[RequireFeature(key)]</c>: gates every endpoint in the group behind the flag.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ namespace Bit.Services.Pam.Api.Models.Response;

/// <summary>
/// Maps <see cref="AccessAuditEventKind"/> to the string vocabulary the governance client expects. The projection
/// emits the request, lease, and rule administration
/// kinds today; the remaining names (credential access, system controls) are defined so the contract stays stable as
/// those kinds come online.
/// emits the request, lease, rule administration, and rotation-lifecycle/fleet-administration kinds today; the
/// remaining names (credential access, system controls) are defined so the contract stays stable as those kinds come
/// online.
/// </summary>
public static class AccessAuditEventKindNames
{
Expand All @@ -29,6 +29,32 @@ public static class AccessAuditEventKindNames
public const string LeasingKillSwitchTriggered = "leasingKillSwitchTriggered";
public const string LeasingFreezeEnabled = "leasingFreezeEnabled";
public const string LeasingFreezeLifted = "leasingFreezeLifted";
public const string RotationConfigCreated = "rotationConfigCreated";
public const string RotationSettingsUpdated = "rotationSettingsUpdated";
public const string RotationAccountUpdated = "rotationAccountUpdated";
public const string RotationPaused = "rotationPaused";
public const string RotationResumed = "rotationResumed";
public const string RotationConfigDeleted = "rotationConfigDeleted";
public const string RotationOffered = "rotationOffered";
public const string RotationDispatched = "rotationDispatched";
public const string RotationSucceeded = "rotationSucceeded";
public const string RotationAttemptFailed = "rotationAttemptFailed";
public const string RotationFailed = "rotationFailed";
public const string RotationJobReleased = "rotationJobReleased";
public const string RotationJobTimedOut = "rotationJobTimedOut";
public const string RotationCipherWriteRejected = "rotationCipherWriteRejected";
public const string RotationReportRejected = "rotationReportRejected";
public const string ManualRotationDue = "manualRotationDue";
public const string ManualRotationRecorded = "manualRotationRecorded";
public const string DaemonRegistered = "daemonRegistered";
public const string DaemonRevoked = "daemonRevoked";
public const string DaemonAssignedToTarget = "daemonAssignedToTarget";
public const string DaemonUnassignedFromTarget = "daemonUnassignedFromTarget";
public const string TargetSystemRegistered = "targetSystemRegistered";
public const string TargetSystemDisabled = "targetSystemDisabled";
public const string TargetSystemEnabled = "targetSystemEnabled";
public const string TargetSystemRenamed = "targetSystemRenamed";
public const string TargetSystemPolicyUpdated = "targetSystemPolicyUpdated";

public static string From(AccessAuditEventKind kind) => kind switch
{
Expand All @@ -51,6 +77,32 @@ public static class AccessAuditEventKindNames
AccessAuditEventKind.LeasingKillSwitchTriggered => LeasingKillSwitchTriggered,
AccessAuditEventKind.LeasingFreezeEnabled => LeasingFreezeEnabled,
AccessAuditEventKind.LeasingFreezeLifted => LeasingFreezeLifted,
AccessAuditEventKind.RotationConfigCreated => RotationConfigCreated,
AccessAuditEventKind.RotationSettingsUpdated => RotationSettingsUpdated,
AccessAuditEventKind.RotationAccountUpdated => RotationAccountUpdated,
AccessAuditEventKind.RotationPaused => RotationPaused,
AccessAuditEventKind.RotationResumed => RotationResumed,
AccessAuditEventKind.RotationConfigDeleted => RotationConfigDeleted,
AccessAuditEventKind.RotationOffered => RotationOffered,
AccessAuditEventKind.RotationDispatched => RotationDispatched,
AccessAuditEventKind.RotationSucceeded => RotationSucceeded,
AccessAuditEventKind.RotationAttemptFailed => RotationAttemptFailed,
AccessAuditEventKind.RotationFailed => RotationFailed,
AccessAuditEventKind.RotationJobReleased => RotationJobReleased,
AccessAuditEventKind.RotationJobTimedOut => RotationJobTimedOut,
AccessAuditEventKind.RotationCipherWriteRejected => RotationCipherWriteRejected,
AccessAuditEventKind.RotationReportRejected => RotationReportRejected,
AccessAuditEventKind.ManualRotationDue => ManualRotationDue,
AccessAuditEventKind.ManualRotationRecorded => ManualRotationRecorded,
AccessAuditEventKind.DaemonRegistered => DaemonRegistered,
AccessAuditEventKind.DaemonRevoked => DaemonRevoked,
AccessAuditEventKind.DaemonAssignedToTarget => DaemonAssignedToTarget,
AccessAuditEventKind.DaemonUnassignedFromTarget => DaemonUnassignedFromTarget,
AccessAuditEventKind.TargetSystemRegistered => TargetSystemRegistered,
AccessAuditEventKind.TargetSystemDisabled => TargetSystemDisabled,
AccessAuditEventKind.TargetSystemEnabled => TargetSystemEnabled,
AccessAuditEventKind.TargetSystemRenamed => TargetSystemRenamed,
AccessAuditEventKind.TargetSystemPolicyUpdated => TargetSystemPolicyUpdated,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent)
RequestId = auditEvent.AccessRequestId;
LeaseId = auditEvent.AccessLeaseId;
RuleId = auditEvent.AccessRuleId;
TargetSystemId = auditEvent.TargetSystemId;
DaemonId = auditEvent.DaemonId;
RotationConfigId = auditEvent.RotationConfigId;
RotationJobId = auditEvent.RotationJobId;
RotationSource = auditEvent.RotationSource;
SyncState = auditEvent.SyncState;
Detail = auditEvent.Detail;
LeaseNotBefore = auditEvent.LeaseNotBefore.AsUtc();
LeaseNotAfter = auditEvent.LeaseNotAfter.AsUtc();
Expand All @@ -38,6 +44,8 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent)
CipherName = auditEvent.CipherName;
CollectionName = auditEvent.CollectionName;
RuleName = auditEvent.RuleName;
TargetSystemName = auditEvent.TargetSystemName;
DaemonName = auditEvent.DaemonName;
Automated = auditEvent.Automated;
Incomplete = auditEvent.Phase == AccessAuditEventPhase.Attempt;
}
Expand All @@ -59,6 +67,16 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent)
public Guid? RequestId { get; }
public Guid? LeaseId { get; }
public Guid? RuleId { get; }
public Guid? TargetSystemId { get; }
public Guid? DaemonId { get; }
public Guid? RotationConfigId { get; }
public Guid? RotationJobId { get; }

/// <summary>What triggered the rotation job (scheduled, on-demand, or access-end); set on job/attempt-scoped events.</summary>
public PamRotationSource? RotationSource { get; }

/// <summary>Whether a failed attempt left the target system's password changed; set on failure/report events.</summary>
public PamRotationSyncState? SyncState { get; }

/// <summary>An approver comment or a revoke reason, if the source carried one.</summary>
public string? Detail { get; }
Expand All @@ -81,6 +99,12 @@ public AccessAuditEventResponseModel(AccessAuditEvent auditEvent)
/// <summary>The access rule's name β€” plaintext org configuration (not vault data), for rule administration events.</summary>
public string? RuleName { get; }

/// <summary>The target system's name β€” plaintext org configuration, snapshotted at write, for rotation/target events.</summary>
public string? TargetSystemName { get; }

/// <summary>The daemon's name β€” plaintext org configuration, snapshotted at write, for rotation/daemon events.</summary>
public string? DaemonName { get; }

/// <summary>True when there is no human actor β€” a system / automatic event.</summary>
public bool Automated { get; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Bit.Pam.Repositories;
using Bit.Pam.Services;
using Bit.Services.Pam.OrganizationFeatures.Commands.Interfaces;
using Bit.Services.Pam.Rotation.Commands.Interfaces;
using Bit.Services.Pam.Services;

namespace Bit.Services.Pam.OrganizationFeatures.Commands;
Expand All @@ -16,22 +17,28 @@ public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand
private readonly IApproverInboxNotifier _approverInboxNotifier;
private readonly IRequesterNotifier _requesterNotifier;
private readonly IAccessAuditEventEmitter _accessAuditEventEmitter;
private readonly IHandleAccessGrantEndedCommand _handleAccessGrantEndedCommand;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RevokeAccessLeaseCommand> _logger;

public RevokeAccessLeaseCommand(
IAccessLeaseRepository accessLeaseRepository,
IApproverCollectionAccessQuery approverCollectionAccessQuery,
IApproverInboxNotifier approverInboxNotifier,
IRequesterNotifier requesterNotifier,
IAccessAuditEventEmitter accessAuditEventEmitter,
TimeProvider timeProvider)
IHandleAccessGrantEndedCommand handleAccessGrantEndedCommand,
TimeProvider timeProvider,
ILogger<RevokeAccessLeaseCommand> logger)
{
_accessLeaseRepository = accessLeaseRepository;
_approverCollectionAccessQuery = approverCollectionAccessQuery;
_approverInboxNotifier = approverInboxNotifier;
_requesterNotifier = requesterNotifier;
_accessAuditEventEmitter = accessAuditEventEmitter;
_handleAccessGrantEndedCommand = handleAccessGrantEndedCommand;
_timeProvider = timeProvider;
_logger = logger;
}

public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason)
Expand Down Expand Up @@ -93,6 +100,20 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason)

await _accessAuditEventEmitter.EmitAsync(audit with { Phase = AccessAuditEventPhase.Outcome });

// Both a holder self-end and an operator revoke are grant-ends (spec RotateOnAccessEnd /
// RaiseManualObligationOnAccessEnd); the handler self-gates on the PamRotation flag. A failure here must
// never fail the revoke itself -- the lease has already ended -- so it is logged and swallowed.
try
{
await _handleAccessGrantEndedCommand.HandleAsync(lease.CipherId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to trigger the rotation access-end handler for cipher {CipherId} after revoking lease {AccessLeaseId}.",
lease.CipherId, lease.Id);
}

// The active lease just drained; tell every approver of this collection to re-fetch.
await _approverInboxNotifier.NotifyCollectionApproversAsync(lease.CollectionId);

Expand Down
Loading
Loading