Skip to content

Commit

Permalink
chore: update LastActivityDate on installation token refresh
Browse files Browse the repository at this point in the history
  • Loading branch information
addisonbeck committed Jan 6, 2025
1 parent cd7c4bf commit 4dcf260
Show file tree
Hide file tree
Showing 13 changed files with 229 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@

namespace Bit.Api.Platform.Installations;

/// <summary>
/// Routes used to manipulate `Installation` objects: a type used to manage
/// a record of a self hosted installation.
/// </summary>
/// <remarks>
/// This controller is not called from any clients. It's primarily referenced
/// in the `Setup` project for creating a new self hosted installation.
/// </remarks>
/// <seealso>Bit.Setup.Program</seealso>
[Route("installations")]
[SelfHosted(NotSelfHostedOnly = true)]
public class InstallationsController : Controller
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ public static class FeatureFlagKeys
public const string AppReviewPrompt = "app-review-prompt";
public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs";
public const string UsePricingService = "use-pricing-service";
public const string RecordInstallationLastActivityDate = "installation-last-activity-date";

public static List<string> GetAllKeys()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Bit.Core.Platform.Installations;

/// <summary>
/// Command interface responsible for updating data on an `Installation`
/// record.
/// </summary>
/// <remarks>
/// This interface is implemented by `UpdateInstallationCommand`
/// </remarks>
/// <seealso cref="Bit.Core.Platform.Installations.UpdateInstallationCommand"/>
public interface IUpdateInstallationCommand
{
Task UpdateLastActivityDateAsync(Guid installationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Bit.Core.Platform.Installations;

/// <summary>
/// Commands responsible for updating an installation from
/// `InstallationRepository`.
/// </summary>
/// <remarks>
/// If referencing: you probably want the interface
/// `IUpdateInstallationCommand` instead of directly calling this class.
/// </remarks>
/// <seealso cref="IUpdateInstallationCommand"/>
public class UpdateInstallationCommand : IUpdateInstallationCommand
{
private readonly IGetInstallationQuery _getInstallationQuery;
private readonly IInstallationRepository _installationRepository;
private readonly TimeProvider _timeProvider;

public UpdateInstallationCommand(
IGetInstallationQuery getInstallationQuery,
IInstallationRepository installationRepository,
TimeProvider timeProvider
)
{
_getInstallationQuery = getInstallationQuery;
_installationRepository = installationRepository;
_timeProvider = timeProvider;
}

public async Task UpdateLastActivityDateAsync(Guid installationId)
{
if (installationId == default)
{
throw new Exception
(
"Tried to update the last activity date for " +
"an installation, but an invalid installation id was " +
"provided."
);

Check warning on line 38 in src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs#L32-L38

Added lines #L32 - L38 were not covered by tests
}
var installation = await _getInstallationQuery.GetByIdAsync(installationId);
if (installation == null)
{
throw new Exception
(
"Tried to update the last activity date for " +
$"installation {installationId.ToString()}, but no " +
"installation was found for that id."
);

Check warning on line 48 in src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs#L42-L48

Added lines #L42 - L48 were not covered by tests
}
installation.LastActivityDate = _timeProvider.GetUtcNow().UtcDateTime;
await _installationRepository.UpsertAsync(installation);
}
}
1 change: 1 addition & 0 deletions src/Core/Platform/Installations/Entities/Installation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class Installation : ITableObject<Guid>
public string Key { get; set; } = null!;
public bool Enabled { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime? LastActivityDate { get; internal set; }

public void SetNewId()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Bit.Core.Platform.Installations;

/// <summary>
/// Queries responsible for fetching an installation from
/// `InstallationRepository`.
/// </summary>
/// <remarks>
/// If referencing: you probably want the interface `IGetInstallationQuery`
/// instead of directly calling this class.
/// </remarks>
/// <seealso cref="IGetInstallationQuery"/>
public class GetInstallationQuery : IGetInstallationQuery
{
private readonly IInstallationRepository _installationRepository;

public GetInstallationQuery(IInstallationRepository installationRepository)
{
_installationRepository = installationRepository;
}

/// <inheritdoc cref="IGetInstallationQuery.GetByIdAsync"/>
public async Task<Installation> GetByIdAsync(Guid installationId)
{

Check warning on line 23 in src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs#L23

Added line #L23 was not covered by tests
if (installationId == default(Guid))
{
return null;

Check warning on line 26 in src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs#L25-L26

Added lines #L25 - L26 were not covered by tests
}
return await _installationRepository.GetByIdAsync(installationId);
}

Check warning on line 29 in src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs

View check run for this annotation

Codecov / codecov/patch

src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs#L28-L29

Added lines #L28 - L29 were not covered by tests
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Bit.Core.Platform.Installations;

/// <summary>
/// Query interface responsible for fetching an installation from
/// `InstallationRepository`.
/// </summary>
/// <remarks>
/// This interface is implemented by `GetInstallationQuery`
/// </remarks>
/// <seealso cref="GetInstallationQuery"/>
public interface IGetInstallationQuery
{
/// <summary>
/// Retrieves an installation from the `InstallationRepository` by its id.
/// </summary>
/// <param name="installationId">The GUID id of the installation.</param>
/// <returns>A task containing an `Installation`.</returns>
/// <seealso cref="T:Bit.Core.Platform.Installations.Repositories.IInstallationRepository"/>
Task<Installation> GetByIdAsync(Guid installationId);
}
19 changes: 19 additions & 0 deletions src/Core/Platform/PlatformServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Bit.Core.Platform.Installations;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Platform;

public static class PlatformServiceCollectionExtensions
{
/// <summary>
/// Extend DI to include commands and queries exported from the Platform
/// domain.
/// </summary>
public static IServiceCollection AddPlatformServices(this IServiceCollection services)
{
services.AddScoped<IGetInstallationQuery, GetInstallationQuery>();
services.AddScoped<IUpdateInstallationCommand, UpdateInstallationCommand>();

return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ namespace Bit.Core.Platform.Push.Internal;

public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService
{

public RelayPushRegistrationService(
IHttpClientFactory httpFactory,
GlobalSettings globalSettings,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Diagnostics;
using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.IdentityServer;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
Expand All @@ -23,6 +25,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
ICustomTokenRequestValidator
{
private readonly UserManager<User> _userManager;
private readonly IUpdateInstallationCommand _updateInstallationCommand;

public CustomTokenRequestValidator(
UserManager<User> userManager,
Expand All @@ -39,7 +42,8 @@ public CustomTokenRequestValidator(
IPolicyService policyService,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IUpdateInstallationCommand updateInstallationCommand
)
: base(
userManager,
Expand All @@ -59,6 +63,7 @@ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
userDecryptionOptionsBuilder)
{
_userManager = userManager;
_updateInstallationCommand = updateInstallationCommand;
}

public async Task ValidateAsync(CustomTokenRequestValidationContext context)
Expand All @@ -76,16 +81,24 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context)
}

string[] allowedGrantTypes = ["authorization_code", "client_credentials"];
string clientId = context.Result.ValidatedRequest.ClientId;
if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType)
|| context.Result.ValidatedRequest.ClientId.StartsWith("organization")
|| context.Result.ValidatedRequest.ClientId.StartsWith("installation")
|| context.Result.ValidatedRequest.ClientId.StartsWith("internal")
|| clientId.StartsWith("organization")
|| clientId.StartsWith("installation")
|| clientId.StartsWith("internal")
|| context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets))
{
if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) &&
!string.IsNullOrWhiteSpace(payload))
{
context.Result.CustomResponse = new Dictionary<string, object> { { "encrypted_payload", payload } };

}
if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate)
&& context.Result.ValidatedRequest.ClientId.StartsWith("installation"))
{
var installationIdPart = clientId.Split(".")[1];
await RecordActivityForInstallation(clientId.Split(".")[1]);

Check warning on line 101 in src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs#L99-L101

Added lines #L99 - L101 were not covered by tests
}
return;
}
Expand Down Expand Up @@ -152,6 +165,7 @@ protected override Task SetSuccessResult(CustomTokenRequestValidationContext con
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
context.Result.CustomResponse["ResetMasterPassword"] = false;
}

return Task.CompletedTask;
}

Expand Down Expand Up @@ -202,4 +216,25 @@ protected override void SetValidationErrorResult(
context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription;
context.Result.CustomResponse = requestContext.CustomResponse;
}

/// <summary>
/// To help mentally separate organizations that self host from abandoned
/// organizations we hook in to the token refresh event for installations
/// to write a simple `DateTime.Now` to the database.
/// </summary>
/// <remarks>
/// This works well because installations don't phone home very often.
/// Currently self hosted installations only refresh tokens every 24
/// hours or so for the sake of hooking in to cloud's push relay service.
/// If installations ever start refreshing tokens more frequently we may need to
/// adjust this to avoid making a bunch of unnecessary database calls!
/// </remarks>
private async Task RecordActivityForInstallation(string? installationIdString)
{

Check warning on line 233 in src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs#L233

Added line #L233 was not covered by tests
if (!Guid.TryParse(installationIdString, out var installationId))
{
return;

Check warning on line 236 in src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs#L235-L236

Added lines #L235 - L236 were not covered by tests
}
await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId);
}

Check warning on line 239 in src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs

View check run for this annotation

Codecov / codecov/patch

src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs#L238-L239

Added lines #L238 - L239 were not covered by tests
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ public WebAuthnGrantValidator(
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
)
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand)
: base(
userManager,
userService,
Expand Down
2 changes: 2 additions & 0 deletions src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
using Bit.Core.NotificationCenter;
using Bit.Core.NotificationHub;
using Bit.Core.OrganizationFeatures;
using Bit.Core.Platform;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -126,6 +127,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett
services.AddReportingServices();
services.AddKeyManagementServices();
services.AddNotificationCenterServices();
services.AddPlatformServices();
}

public static void AddTokenizers(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;

namespace Bit.Core.Platform.Installations.Tests;

[SutProviderCustomize]
public class UpdateInstallationCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateLastActivityDateAsync_ShouldUpdateLastActivityDate(
Installation installation
)
{
// Arrange
var sutProvider = new SutProvider<UpdateInstallationCommand>()
.WithFakeTimeProvider()
.Create();

var someDate = new DateTime(2014, 11, 3, 18, 27, 0, DateTimeKind.Utc);
sutProvider.GetDependency<FakeTimeProvider>().SetUtcNow(someDate);

sutProvider
.GetDependency<IGetInstallationQuery>()
.GetByIdAsync(installation.Id)
.Returns(installation);

// Act
await sutProvider.Sut.UpdateLastActivityDateAsync(installation.Id);

// Assert
await sutProvider
.GetDependency<IInstallationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Installation>(inst => inst.LastActivityDate == someDate));
}
}

0 comments on commit 4dcf260

Please sign in to comment.