diff --git a/src/Api/Platform/Installations/Controllers/InstallationsController.cs b/src/Api/Platform/Installations/Controllers/InstallationsController.cs
index a9ba4e6c02b9..96cdc9d95cec 100644
--- a/src/Api/Platform/Installations/Controllers/InstallationsController.cs
+++ b/src/Api/Platform/Installations/Controllers/InstallationsController.cs
@@ -6,6 +6,15 @@
namespace Bit.Api.Platform.Installations;
+///
+/// Routes used to manipulate `Installation` objects: a type used to manage
+/// a record of a self hosted installation.
+///
+///
+/// This controller is not called from any clients. It's primarily referenced
+/// in the `Setup` project for creating a new self hosted installation.
+///
+/// Bit.Setup.Program
[Route("installations")]
[SelfHosted(NotSelfHostedOnly = true)]
public class InstallationsController : Controller
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 0b7435cf8f32..830e3f65b221 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -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 GetAllKeys()
{
diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs
new file mode 100644
index 000000000000..d0c25b96a455
--- /dev/null
+++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs
@@ -0,0 +1,14 @@
+namespace Bit.Core.Platform.Installations;
+
+///
+/// Command interface responsible for updating data on an `Installation`
+/// record.
+///
+///
+/// This interface is implemented by `UpdateInstallationCommand`
+///
+///
+public interface IUpdateInstallationCommand
+{
+ Task UpdateLastActivityDateAsync(Guid installationId);
+}
diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs
new file mode 100644
index 000000000000..4b0bc3bbe804
--- /dev/null
+++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs
@@ -0,0 +1,53 @@
+namespace Bit.Core.Platform.Installations;
+
+///
+/// Commands responsible for updating an installation from
+/// `InstallationRepository`.
+///
+///
+/// If referencing: you probably want the interface
+/// `IUpdateInstallationCommand` instead of directly calling this class.
+///
+///
+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."
+ );
+ }
+ 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."
+ );
+ }
+ installation.LastActivityDate = _timeProvider.GetUtcNow().UtcDateTime;
+ await _installationRepository.UpsertAsync(installation);
+ }
+}
diff --git a/src/Core/Platform/Installations/Entities/Installation.cs b/src/Core/Platform/Installations/Entities/Installation.cs
index 63aa5d1e2458..acd53db0fb01 100644
--- a/src/Core/Platform/Installations/Entities/Installation.cs
+++ b/src/Core/Platform/Installations/Entities/Installation.cs
@@ -19,6 +19,7 @@ public class Installation : ITableObject
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()
{
diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs
new file mode 100644
index 000000000000..b0d87458006d
--- /dev/null
+++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs
@@ -0,0 +1,30 @@
+namespace Bit.Core.Platform.Installations;
+
+///
+/// Queries responsible for fetching an installation from
+/// `InstallationRepository`.
+///
+///
+/// If referencing: you probably want the interface `IGetInstallationQuery`
+/// instead of directly calling this class.
+///
+///
+public class GetInstallationQuery : IGetInstallationQuery
+{
+ private readonly IInstallationRepository _installationRepository;
+
+ public GetInstallationQuery(IInstallationRepository installationRepository)
+ {
+ _installationRepository = installationRepository;
+ }
+
+ ///
+ public async Task GetByIdAsync(Guid installationId)
+ {
+ if (installationId == default(Guid))
+ {
+ return null;
+ }
+ return await _installationRepository.GetByIdAsync(installationId);
+ }
+}
diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs
new file mode 100644
index 000000000000..9615cf986d27
--- /dev/null
+++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs
@@ -0,0 +1,20 @@
+namespace Bit.Core.Platform.Installations;
+
+///
+/// Query interface responsible for fetching an installation from
+/// `InstallationRepository`.
+///
+///
+/// This interface is implemented by `GetInstallationQuery`
+///
+///
+public interface IGetInstallationQuery
+{
+ ///
+ /// Retrieves an installation from the `InstallationRepository` by its id.
+ ///
+ /// The GUID id of the installation.
+ /// A task containing an `Installation`.
+ ///
+ Task GetByIdAsync(Guid installationId);
+}
diff --git a/src/Core/Platform/PlatformServiceCollectionExtensions.cs b/src/Core/Platform/PlatformServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..bba0b0aeddec
--- /dev/null
+++ b/src/Core/Platform/PlatformServiceCollectionExtensions.cs
@@ -0,0 +1,19 @@
+using Bit.Core.Platform.Installations;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Core.Platform;
+
+public static class PlatformServiceCollectionExtensions
+{
+ ///
+ /// Extend DI to include commands and queries exported from the Platform
+ /// domain.
+ ///
+ public static IServiceCollection AddPlatformServices(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
index a42a831266fa..79b033e87717 100644
--- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
+++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
@@ -9,7 +9,6 @@ namespace Bit.Core.Platform.Push.Internal;
public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService
{
-
public RelayPushRegistrationService(
IHttpClientFactory httpFactory,
GlobalSettings globalSettings,
diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
index fb7b129b09af..597d5257e2ce 100644
--- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs
@@ -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;
@@ -23,6 +25,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator _userManager;
+ private readonly IUpdateInstallationCommand _updateInstallationCommand;
public CustomTokenRequestValidator(
UserManager userManager,
@@ -39,7 +42,8 @@ public CustomTokenRequestValidator(
IPolicyService policyService,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
- IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
+ IUpdateInstallationCommand updateInstallationCommand
)
: base(
userManager,
@@ -59,6 +63,7 @@ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
userDecryptionOptionsBuilder)
{
_userManager = userManager;
+ _updateInstallationCommand = updateInstallationCommand;
}
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
@@ -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 { { "encrypted_payload", payload } };
+
+ }
+ if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate)
+ && context.Result.ValidatedRequest.ClientId.StartsWith("installation"))
+ {
+ var installationIdPart = clientId.Split(".")[1];
+ await RecordActivityForInstallation(clientId.Split(".")[1]);
}
return;
}
@@ -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;
}
@@ -202,4 +216,25 @@ protected override void SetValidationErrorResult(
context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription;
context.Result.CustomResponse = requestContext.CustomResponse;
}
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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!
+ ///
+ private async Task RecordActivityForInstallation(string? installationIdString)
+ {
+ if (!Guid.TryParse(installationIdString, out var installationId))
+ {
+ return;
+ }
+ await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId);
+ }
}
diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
index 499c22ad8961..085ed15efd90 100644
--- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs
@@ -44,8 +44,7 @@ public WebAuthnGrantValidator(
IDataProtectorTokenFactory assertionOptionsDataProtector,
IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
- IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
- )
+ IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand)
: base(
userManager,
userService,
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index 46f8293d3eab..891b8d6664bc 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -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;
@@ -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)
diff --git a/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs
new file mode 100644
index 000000000000..daa8e1b89cba
--- /dev/null
+++ b/test/Core.Test/Platform/Installations/Commands/UpdateInstallationCommandTests.cs
@@ -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()
+ .WithFakeTimeProvider()
+ .Create();
+
+ var someDate = new DateTime(2014, 11, 3, 18, 27, 0, DateTimeKind.Utc);
+ sutProvider.GetDependency().SetUtcNow(someDate);
+
+ sutProvider
+ .GetDependency()
+ .GetByIdAsync(installation.Id)
+ .Returns(installation);
+
+ // Act
+ await sutProvider.Sut.UpdateLastActivityDateAsync(installation.Id);
+
+ // Assert
+ await sutProvider
+ .GetDependency()
+ .Received(1)
+ .UpsertAsync(Arg.Is(inst => inst.LastActivityDate == someDate));
+ }
+}