diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 36fdd2a99..ace46d438 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -50,6 +50,7 @@ public class OrganizationsController : Controller private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; + private readonly ILicensingService _licensingService; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -69,7 +70,8 @@ public class OrganizationsController : Controller IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand, ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + ILicensingService licensingService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -89,6 +91,7 @@ public class OrganizationsController : Controller _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; + _licensingService = licensingService; } [HttpGet("{id}")] @@ -156,10 +159,14 @@ public class OrganizationsController : Controller return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); } - else + + if (_globalSettings.SelfHosted) { - return new OrganizationSubscriptionResponseModel(organization); + var orgLicense = await _licensingService.ReadOrganizationLicenseAsync(organization); + return new OrganizationSubscriptionResponseModel(organization, orgLicense); } + + return new OrganizationSubscriptionResponseModel(organization); } [HttpGet("{id}/license")] diff --git a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs index d2fdc1e0b..8b9d94d88 100644 --- a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Utilities; +using Constants = Bit.Core.Constants; namespace Bit.Api.Models.Response.Organizations; @@ -108,9 +109,32 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } } + public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license) : + this(organization) + { + if (license != null) + { + // License expiration should always include grace period - See OrganizationLicense.cs + Expiration = license.Expires; + // Use license.ExpirationWithoutGracePeriod if available, otherwise assume license expiration minus grace period + ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? + license.Expires?.AddDays(-Constants + .OrganizationSelfHostSubscriptionGracePeriodDays); + } + } + public string StorageName { get; set; } public double? StorageGb { get; set; } public BillingSubscription Subscription { get; set; } public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } + + /// + /// Date when a self-hosted organization's subscription expires, without any grace period. + /// + public DateTime? ExpirationWithoutGracePeriod { get; set; } + + /// + /// Date when a self-hosted organization expires (includes grace period). + /// public DateTime? Expiration { get; set; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8ae79084d..8ff378d00 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -13,6 +13,12 @@ public static class Constants public const long FileSize501mb = 501L * 1024L * 1024L; public const string DatabaseFieldProtectorPurpose = "DatabaseFieldProtection"; public const string DatabaseFieldProtectedPrefix = "P|"; + + /// + /// Default number of days an organization has to apply an updated license to their self-hosted installation after + /// their subscription has expired. + /// + public const int OrganizationSelfHostSubscriptionGracePeriodDays = 60; } public static class TokenPurposes diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index 58cce4cff..c72430d94 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -78,7 +78,8 @@ public class OrganizationLicense : ILicense subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) { Refresh = DateTime.UtcNow.AddDays(30); - Expires = subscriptionInfo?.Subscription.PeriodEndDate.Value.AddDays(60); + Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + ExpirationWithoutGracePeriod = subscriptionInfo.Subscription.PeriodEndDate; } else { @@ -123,6 +124,7 @@ public class OrganizationLicense : ILicense public DateTime Issued { get; set; } public DateTime? Refresh { get; set; } public DateTime? Expires { get; set; } + public DateTime? ExpirationWithoutGracePeriod { get; set; } public bool Trial { get; set; } public LicenseType? LicenseType { get; set; } public string Hash { get; set; } @@ -133,10 +135,12 @@ public class OrganizationLicense : ILicense /// /// Represents the current version of the license format. Should be updated whenever new fields are added. /// - private const int CURRENT_LICENSE_FILE_VERSION = 10; + /// Intentionally set one version behind to allow self hosted users some time to update before + /// getting out of date license errors + private const int CURRENT_LICENSE_FILE_VERSION = 11; private bool ValidLicenseVersion { - get => Version is >= 1 and <= 11; + get => Version is >= 1 and <= 12; } public byte[] GetDataBytes(bool forHash = false) @@ -170,6 +174,8 @@ public class OrganizationLicense : ILicense (Version >= 10 || !p.Name.Equals(nameof(UseScim))) && // UseCustomPermissions was added in Version 11 (Version >= 11 || !p.Name.Equals(nameof(UseCustomPermissions))) && + // ExpirationWithoutGracePeriod was added in Version 12 + (Version >= 12 || !p.Name.Equals(nameof(ExpirationWithoutGracePeriod))) && ( !forHash || ( diff --git a/test/Api.Test/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Controllers/OrganizationsControllerTests.cs index aeadbb104..069b4bb27 100644 --- a/test/Api.Test/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationsControllerTests.cs @@ -40,6 +40,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IFeatureService _featureService; + private readonly ILicensingService _licensingService; private readonly OrganizationsController _sut; @@ -63,12 +64,13 @@ public class OrganizationsControllerTests : IDisposable _createOrganizationApiKeyCommand = Substitute.For(); _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); + _licensingService = Substitute.For(); _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings); + _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService); } public void Dispose()