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 @@ -15,6 +15,7 @@ public class RevokeAccessLeaseCommand : IRevokeAccessLeaseCommand
private readonly IApproverCollectionAccessQuery _approverCollectionAccessQuery;
private readonly IApproverInboxNotifier _approverInboxNotifier;
private readonly IRequesterNotifier _requesterNotifier;
private readonly ILeaseRevokedMailNotifier _leaseRevokedMailNotifier;
private readonly IAccessAuditEventEmitter _accessAuditEventEmitter;
private readonly IHandleAccessGrantEndedCommand _handleAccessGrantEndedCommand;
private readonly TimeProvider _timeProvider;
Expand All @@ -25,6 +26,7 @@ public RevokeAccessLeaseCommand(
IApproverCollectionAccessQuery approverCollectionAccessQuery,
IApproverInboxNotifier approverInboxNotifier,
IRequesterNotifier requesterNotifier,
ILeaseRevokedMailNotifier leaseRevokedMailNotifier,
IAccessAuditEventEmitter accessAuditEventEmitter,
IHandleAccessGrantEndedCommand handleAccessGrantEndedCommand,
TimeProvider timeProvider,
Expand All @@ -34,6 +36,7 @@ public RevokeAccessLeaseCommand(
_approverCollectionAccessQuery = approverCollectionAccessQuery;
_approverInboxNotifier = approverInboxNotifier;
_requesterNotifier = requesterNotifier;
_leaseRevokedMailNotifier = leaseRevokedMailNotifier;
_accessAuditEventEmitter = accessAuditEventEmitter;
_handleAccessGrantEndedCommand = handleAccessGrantEndedCommand;
_timeProvider = timeProvider;
Expand Down Expand Up @@ -117,5 +120,9 @@ public async Task RevokeAsync(Guid userId, Guid leaseId, string? reason)

// Tell the lease holder their access ended, so an open cipher re-locks and the badges drop the lease.
await _requesterNotifier.NotifyRequesterAsync(lease.RequesterId);

// The same news out of band: the push above only lands on a client that is already open. Every early end is
// handed over, and only a revocation is mailed -- a holder is not mailed about ending their own access.
await _leaseRevokedMailNotifier.NotifyLeaseEndedAsync(lease, endAction);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
ο»Ώusing Bit.Pam.Entities;
using Bit.Pam.Enums;

namespace Bit.Services.Pam.Services;

/// <summary>
/// Emails a lease holder that an operator ended their active access: the out-of-band twin of the
/// <c>RefreshAccessRequest</c> push from <see cref="IRequesterNotifier" /> on the same path.
/// </summary>
/// <remarks>
/// A courtesy, not a security control. The lease is dead server-side before anything here runs, so nothing this
/// type does or fails to do changes who holds access.
///
/// Like <see cref="IAccessMailNotifier" />, it never throws: the call sits at the end of the command that ended
/// the lease, and a mail outage must not fail a revocation that has already been written.
/// </remarks>
public interface ILeaseRevokedMailNotifier
{
/// <summary>
/// Tells <paramref name="lease" />'s holder that their access ended, but only when
/// <paramref name="endAction" /> is <see cref="AccessLeaseAction.Revoked" />. The name is neutral because this
/// is handed every early end, so the rule that a holder is never mailed about their own action lives here.
/// </summary>
/// <param name="lease">The lease just ended. Its <c>Action</c> may not be stamped yet at the call site.</param>
/// <param name="endAction">
/// How it ended, passed rather than read from <paramref name="lease" /> for that reason:
/// <see cref="AccessLeaseAction.Revoked" /> for an operator, <see cref="AccessLeaseAction.Cancelled" /> for the
/// holder ending their own access.
/// </param>
Task NotifyLeaseEndedAsync(AccessLease lease, AccessLeaseAction endAction);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
ο»Ώusing Bit.Core;
using Bit.Core.Pam.Models.Mail.AccessLeaseRevoked;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Pam.Entities;
using Bit.Pam.Enums;
using Bitwarden.Server.Sdk.Features;

namespace Bit.Services.Pam.Services;

public class LeaseRevokedMailNotifier : ILeaseRevokedMailNotifier
{
private readonly IAccessMailNotifier _accessMailNotifier;
private readonly IOrganizationRepository _organizationRepository;
private readonly IGlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly ILogger<LeaseRevokedMailNotifier> _logger;

public LeaseRevokedMailNotifier(
IAccessMailNotifier accessMailNotifier,
IOrganizationRepository organizationRepository,
IGlobalSettings globalSettings,
IFeatureService featureService,
ILogger<LeaseRevokedMailNotifier> logger)
{
_accessMailNotifier = accessMailNotifier;
_organizationRepository = organizationRepository;
_globalSettings = globalSettings;
_featureService = featureService;
_logger = logger;
Comment on lines +26 to +30

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs null checks, such as _foo = foo ?? throw new ArgumentNullException(nameof(foo)).

}

public async Task NotifyLeaseEndedAsync(AccessLease lease, AccessLeaseAction endAction)
{
if (endAction != AccessLeaseAction.Revoked)
{
return;
}

// Duplicates the guard inside IAccessMailNotifier to keep the organization read off every revocation in the
// flag-off state, which is every revocation on self-host.
if (!_featureService.IsEnabled(FeatureFlagKeys.Pam))
{
return;
}

try
{
var organization = await _organizationRepository.GetByIdAsync(lease.OrganizationId);
if (organization is null)
{
_logger.LogWarning(
"PAM lease-revoked mail for lease {AccessLeaseId}: organization could not be resolved; nothing sent.",
lease.Id);
return;
}

var view = new AccessLeaseRevokedView
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
AccessRequestId = lease.AccessRequestId,
OrganizationName = organization.Name,
NotAfter = lease.NotAfter,
};

await _accessMailNotifier.SendToUserAsync(
lease.RequesterId, email => new AccessLeaseRevokedMail { ToEmails = [email], View = view });
}
catch (Exception ex)
{
// Ids only: not the holder's address, and never the reason the operator gave for revoking.
_logger.LogError(ex, "PAM lease-revoked mail for lease {AccessLeaseId} could not be sent.", lease.Id);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public static IServiceCollection AddPamServices(this IServiceCollection services
services.TryAddScoped<IAccessMailNotifier, AccessMailNotifier>();
services.TryAddScoped<IApproverMailNotifier, ApproverMailNotifier>();
services.TryAddScoped<IRequesterMailNotifier, RequesterMailNotifier>();
services.TryAddScoped<ILeaseRevokedMailNotifier, LeaseRevokedMailNotifier>();

// Registered explicitly, unlike a parameterless-constructor filter, since it resolves services of its own.
services.AddScoped<AccessConnectorHeartbeatEndpointFilter>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,12 @@ public async Task RevokeAsync_HolderEndsOwnLease_RevokesWithoutManageRights(Acce
var sutProvider = Setup();
lease.Action = AccessLeaseAction.None;
lease.NotAfter = _now.AddHours(1);
// The caller holds the lease but cannot Manage the collection; they may still end their own access.
sutProvider.GetDependency<IAccessLeaseRepository>().GetByIdAsync(lease.Id).Returns(lease);
sutProvider.GetDependency<IApproverCollectionAccessQuery>()
.CanManageCollectionAsync(lease.RequesterId, lease.CollectionId).Returns(false);

await sutProvider.Sut.RevokeAsync(lease.RequesterId, lease.Id, "done with it");

// Settles to Cancelled (the holder ended their own access) with the holder recorded as the revoker.
await sutProvider.GetDependency<IAccessLeaseRepository>().Received(1).RevokeAsync(
lease,
AccessLeaseAction.Cancelled,
Expand All @@ -67,6 +65,8 @@ await sutProvider.GetDependency<IApproverInboxNotifier>().Received(1)
.NotifyCollectionApproversAsync(lease.CollectionId);
await sutProvider.GetDependency<IRequesterNotifier>().Received(1)
.NotifyRequesterAsync(lease.RequesterId);
await sutProvider.GetDependency<ILeaseRevokedMailNotifier>().Received(1)
.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Cancelled);
}

[Theory, BitAutoData]
Expand Down Expand Up @@ -104,6 +104,8 @@ await sutProvider.GetDependency<IApproverInboxNotifier>().Received(1)
.NotifyCollectionApproversAsync(lease.CollectionId);
await sutProvider.GetDependency<IRequesterNotifier>().Received(1)
.NotifyRequesterAsync(lease.RequesterId);
await sutProvider.GetDependency<ILeaseRevokedMailNotifier>().Received(1)
.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked);
}

[Theory, BitAutoData]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
ο»Ώusing Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Pam.Models.Mail.AccessLeaseRevoked;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Pam.Entities;
using Bit.Pam.Enums;
using Bit.Services.Pam.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bitwarden.Server.Sdk.Features;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;

namespace Bit.Services.Pam.Test.Services;

public class LeaseRevokedMailNotifierTests
{
private const string _vaultUrl = "https://vault.example.com/#";
private const string _organizationName = "Contoso";

[Theory, BitAutoData]
public async Task NotifyLeaseEndedAsync_Revoked_MailsTheHolderWithTheWindowItCutShort(AccessLease lease)
{
lease.NotAfter = new DateTime(2026, 9, 1, 17, 0, 0, DateTimeKind.Utc);

var sutProvider = Setup();
SetupOrganization(sutProvider, lease);
var sent = RecordMail(sutProvider);

await sutProvider.Sut.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked);

var (recipientId, mail) = Assert.Single(sent);
Assert.Equal(lease.RequesterId, recipientId);
Assert.Equal("Your access was revoked", mail.Subject);
Assert.Equal(_organizationName, mail.View.OrganizationName);
Assert.Equal("1 Sep 2026 at 17:00 UTC", mail.View.ScheduledEnd);
Assert.Equal($"{_vaultUrl}/pam/requests/{lease.AccessRequestId}", mail.View.Url);
// One message per recipient: the holder is never named alongside anyone else.
await sutProvider.GetDependency<IAccessMailNotifier>().DidNotReceiveWithAnyArgs()
.SendToUsersAsync(default!, (Func<string, BaseMail<AccessLeaseRevokedView>>)default!);
}

[Theory]
[BitAutoData(AccessLeaseAction.Cancelled)]
[BitAutoData(AccessLeaseAction.None)]
public async Task NotifyLeaseEndedAsync_NotRevoked_ReadsNothingAndSendsNothing(
AccessLeaseAction endAction, AccessLease lease)
{
var sutProvider = Setup();
SetupOrganization(sutProvider, lease);
var sent = RecordMail(sutProvider);

await sutProvider.Sut.NotifyLeaseEndedAsync(lease, endAction);

Assert.Empty(sent);
await sutProvider.GetDependency<IAccessMailNotifier>().DidNotReceiveWithAnyArgs()
.SendToUserAsync(default, (Func<string, BaseMail<AccessLeaseRevokedView>>)default!);
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(default);
}

[Theory, BitAutoData]
public async Task NotifyLeaseEndedAsync_FlagOff_ReadsNothingAndSendsNothing(AccessLease lease)
{
var sutProvider = Setup(flagOn: false);

await sutProvider.Sut.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked);

await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(default);
await sutProvider.GetDependency<IAccessMailNotifier>().DidNotReceiveWithAnyArgs()
.SendToUserAsync(default, (Func<string, BaseMail<AccessLeaseRevokedView>>)default!);
}

[Theory, BitAutoData]
public async Task NotifyLeaseEndedAsync_UnknownOrganization_SendsNothing(AccessLease lease)
{
var sutProvider = Setup();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(lease.OrganizationId)
.Returns((Organization?)null);
var sent = RecordMail(sutProvider);

await sutProvider.Sut.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked);

Assert.Empty(sent);
}

[Theory, BitAutoData]
public async Task NotifyLeaseEndedAsync_SendFails_DoesNotPropagate(AccessLease lease)
{
var sutProvider = Setup();
SetupOrganization(sutProvider, lease);
sutProvider.GetDependency<IAccessMailNotifier>()
.SendToUserAsync(Arg.Any<Guid>(), Arg.Any<Func<string, BaseMail<AccessLeaseRevokedView>>>())
.ThrowsAsync(new InvalidOperationException("delivery service unavailable"));

var exception = await Record.ExceptionAsync(
() => sutProvider.Sut.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked));

Assert.Null(exception);
}

[Theory, BitAutoData]
public async Task NotifyLeaseEndedAsync_OrganizationReadFails_DoesNotPropagate(AccessLease lease)
{
var sutProvider = Setup();
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(lease.OrganizationId)
.ThrowsAsync(new TimeoutException("database unavailable"));

var exception = await Record.ExceptionAsync(
() => sutProvider.Sut.NotifyLeaseEndedAsync(lease, AccessLeaseAction.Revoked));

Assert.Null(exception);
}

private static SutProvider<LeaseRevokedMailNotifier> Setup(bool flagOn = true)
{
var sutProvider = new SutProvider<LeaseRevokedMailNotifier>().Create();

sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.Pam)
.Returns(flagOn);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri.VaultWithHash.Returns(_vaultUrl);

return sutProvider;
}

private static void SetupOrganization(SutProvider<LeaseRevokedMailNotifier> sutProvider, AccessLease lease) =>
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(lease.OrganizationId)
.Returns(new Organization { Id = lease.OrganizationId, Name = _organizationName });

private static List<(Guid RecipientId, AccessLeaseRevokedMail Mail)> RecordMail(
SutProvider<LeaseRevokedMailNotifier> sutProvider)
{
List<(Guid RecipientId, AccessLeaseRevokedMail Mail)> sent = [];

sutProvider.GetDependency<IAccessMailNotifier>()
.When(x => x.SendToUserAsync(
Arg.Any<Guid>(),
Arg.Any<Func<string, BaseMail<AccessLeaseRevokedView>>>()))
.Do(call => sent.Add((
call.Arg<Guid>(),
(AccessLeaseRevokedMail)call.Arg<Func<string, BaseMail<AccessLeaseRevokedView>>>()(
"holder@example.com"))));

return sent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<mjml>
<mj-head>
<mj-include path="../../components/head.mjml" />
</mj-head>
<mj-body>
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 24px 0px 24px">
<mj-bw-simple-hero />
</mj-wrapper>

<!-- Main Content -->
<mj-wrapper padding="0px 25px">
<mj-section background-color="#fff" padding="24px 0px">
<mj-column padding="0px 25px">
<mj-text padding="0px 0px 16px 0px" font-weight="600" font-size="18px" line-height="28px">
Your access was revoked
</mj-text>
<mj-text padding="0px 0px 24px 0px" font-weight="400" line-height="24px">
Someone who manages this collection revoked your access in <b>{{OrganizationName}}</b>, which was due to
run until {{ScheduledEnd}}. The item is locked again, and this access cannot be resumed.
</mj-text>
<mj-button
padding="0px 0px 24px 0px"
inner-padding="12px 24px"
border-radius="20px"
font-weight="600"
align="left"
href="{{{Url}}}"
>View the request</mj-button
>
<mj-text padding="0px" font-size="12px" font-weight="400" line-height="24px">
If you still need access, ask for it again with a new request. Any reason given for revoking this one is
on the request, along with the item it covered; neither is included in this email.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>

<!-- Footer -->
<mj-wrapper padding-top="15px">
<mj-include path="../../components/footer.mjml" />
</mj-wrapper>
</mj-body>
</mjml>
Loading
Loading