diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fa1a49e..c1b8f5c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Disable users to exclude them from license seat count (#427, #428) - Display a banner to indicate that legacy devices are still in use, since these will be removed in the next major release (#420) - Published Helm chart (#406, #456) +- Grace period after license expiry: vaults stay accessible during this period and a banner prompts to renew the license ### Changed diff --git a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java index 928a0b33a..59ebef2fc 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/LicenseResource.java @@ -45,7 +45,7 @@ public class LicenseResource { @Path("/user-info") @Produces(MediaType.APPLICATION_JSON) @RolesAllowed("user") - @Operation(summary = "Get license information for regular users", description = "Information includes the licensed seats, the already used seats and if defined, the license expiration date.") + @Operation(summary = "Get license information for regular users", description = "Information includes the licensed seats, the already used seats, the license expiration date and the end of the grace period.") @APIResponse(responseCode = "200") public LicenseUserInfoDto get() { int usedSeats = (int) effectiveVaultAccessRepo.countSeatOccupyingUsers(); @@ -54,12 +54,14 @@ public LicenseUserInfoDto get() { public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensedSeats, @JsonProperty("usedSeats") Integer usedSeats, - @JsonProperty("expiresAt") Instant expiresAt) { + @JsonProperty("expiresAt") Instant expiresAt, + @JsonProperty("gracePeriodEndsAt") Instant gracePeriodEndsAt) { public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) { var licensedSeats = (int) licenseHolder.getEntitlements().seats(); - var expiresAt = licenseHolder.get().getExpiresAtAsInstant(); - return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt); + var expiresAt = licenseHolder.getExpiresAt(); + var gracePeriodEndsAt = licenseHolder.getGracePeriodEndsAt(); + return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt, gracePeriodEndsAt); } } diff --git a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java index b956e2949..e56ca965f 100644 --- a/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java +++ b/backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; @@ -32,6 +33,13 @@ public class LicenseHolder { private static final Logger LOG = Logger.getLogger(LicenseHolder.class); + /** + * Grace period after the license's expiry date during which the license is still treated as active, + * bridging the gap between the end of a billing period and the license server confirming the renewal. + */ + // visible for testing + static final Duration GRACE_PERIOD = Duration.ofDays(9); + private final Boolean managedInstance; private final Optional initialId; private final Optional initialLicenseToken; @@ -293,12 +301,30 @@ private LicenseState state() { } /** - * Checks if the license is expired. + * Returns the expiry date of the license. + * + * @return the license's {@code exp} claim + */ + public Instant getExpiresAt() { + return get().getExpiresAtAsInstant(); + } + + /** + * Returns the instant after which the license is considered {@link #isExpired() expired}. + * + * @return the license's expiry date plus {@link #GRACE_PERIOD} + */ + public Instant getGracePeriodEndsAt() { + return getExpiresAt().plus(GRACE_PERIOD); + } + + /** + * Checks if the license is expired, granting a grace period of {@link #GRACE_PERIOD}. * - * @return {@code true}, if the license expired, {@code false} otherwise. + * @return {@code true}, if the license expired more than {@link #GRACE_PERIOD} ago, {@code false} otherwise. */ public boolean isExpired() { - return get().getExpiresAt().toInstant().isBefore(Instant.now()); + return getGracePeriodEndsAt().isBefore(Instant.now()); } public boolean isManagedInstance() { diff --git a/backend/src/test/java/org/cryptomator/hub/api/LicenseResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/LicenseResourceTest.java index 67898047e..0cf6b5cb9 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/LicenseResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/LicenseResourceTest.java @@ -10,6 +10,7 @@ import io.restassured.http.ContentType; import jakarta.ws.rs.NotFoundException; import org.cryptomator.hub.entities.EffectiveVaultAccess; +import org.cryptomator.hub.license.HubLicenseEntitlements; import org.cryptomator.hub.license.LicenseHolder; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; @@ -17,11 +18,13 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import java.time.Instant; import java.util.Map; import java.util.UUID; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.is; @QuarkusTest @DisplayName("Resource /license") @@ -145,6 +148,32 @@ void testRefreshSessionUnknown() throws LicenseHolder.LicenseRefreshFailedExcept } + @Nested + @DisplayName("As user") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + class AsUser { + + @Test + @DisplayName("GET /license/user-info returns 200 with seats, expiry and grace period end") + void testGetUserInfo() { + Mockito.doReturn(Instant.parse("2026-07-27T00:00:00Z")).when(licenseHolder).getExpiresAt(); + Mockito.doReturn(Instant.parse("2026-08-05T00:00:00Z")).when(licenseHolder).getGracePeriodEndsAt(); + Mockito.doReturn(HubLicenseEntitlements.create().withSeats(5)).when(licenseHolder).getEntitlements(); + Mockito.doReturn(3L).when(effectiveVaultAccessRepo).countSeatOccupyingUsers(); + + when().get("/license/user-info") + .then().statusCode(200) + .body("licensedSeats", is(5)) + .body("usedSeats", is(3)) + .body("expiresAt", is("2026-07-27T00:00:00Z")) + .body("gracePeriodEndsAt", is("2026-08-05T00:00:00Z")); + } + + } + @Nested @DisplayName("As any other role") @TestSecurity(user = "User Name 1", roles = {"user"}) @@ -194,6 +223,13 @@ void testRefreshSessionDuringSetup() { @DisplayName("As unauthenticated user") class AsAnonymous { + @Test + @DisplayName("GET /license/user-info returns 401 Unauthorized") + void testGetUserInfo() { + when().get("/license/user-info") + .then().statusCode(401); + } + @Test @DisplayName("PUT /license/trial returns 401 Unauthorized") void testInstallTrial() { diff --git a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java index cc13fa9dd..92d338fcd 100644 --- a/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/license/LicenseHolderTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mockito; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; import java.util.UUID; @@ -498,4 +500,59 @@ void testUpstreamFailure() { } } + @Nested + @DisplayName("Testing isExpired()") + class TestIsExpired { + + private LicenseHolder licenseHolderSpy; + private DecodedJWT licenseJwt; + + @BeforeEach + void setup() { + licenseHolderSpy = Mockito.spy(licenseHolder); + licenseJwt = mock(DecodedJWT.class); + Mockito.doReturn(licenseJwt).when(licenseHolderSpy).get(); + } + + @Test + @DisplayName("license expiring in the future is not expired") + void testNotExpired() { + Mockito.doReturn(Instant.now().plus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertFalse(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("license expired within grace period is not considered expired") + void testExpiredWithinGracePeriod() { + Mockito.doReturn(Instant.now().minus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertFalse(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("license expired almost beyond grace period is not considered expired") + void testExpiredAlmostBeyondGracePeriod() { + Mockito.doReturn(Instant.now().minus(LicenseHolder.GRACE_PERIOD).plus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertFalse(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("license expired beyond grace period is considered expired") + void testExpiredBeyondGracePeriod() { + Mockito.doReturn(Instant.now().minus(LicenseHolder.GRACE_PERIOD).minus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertTrue(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("grace period ends 9 days after the expiry date") + void testGracePeriodEndsAt() { + Mockito.doReturn(Instant.parse("2026-07-27T00:00:00Z")).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertEquals(Instant.parse("2026-08-05T00:00:00Z"), licenseHolderSpy.getGracePeriodEndsAt()); + } + } + } diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index bbbb454ab..c11362fd1 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -229,18 +229,23 @@ export class LicenseUserInfoDto { constructor( public licensedSeats: number, public usedSeats: number, - public expiresAt: Date | null) { + public expiresAt: Date | null, + public gracePeriodEndsAt: Date | null) { } - public isExpired(): boolean { - const now = new Date(); - return now > (this.expiresAt ?? now); //if expired is null, the license cannot expire + public isExpired(mode?: 'allowGracePeriod'): boolean { + const deadline = mode === 'allowGracePeriod' ? this.gracePeriodEndsAt : this.expiresAt; + return deadline != null && new Date() > deadline; // no deadline means no expiration date, i.e. the license cannot expire } public isExceeded(): boolean { return this.licensedSeats == 0 || this.usedSeats > this.licensedSeats; } + public isViolated(): boolean { + return this.isExpired('allowGracePeriod') || this.isExceeded(); + } + } export interface VaultIdHeader extends JWTHeader { @@ -652,13 +657,15 @@ class LicenseService { public async getUserInfo(): Promise { return axiosAuth.get('/license/user-info').then(response => { - return new LicenseUserInfoDto(response.data.licensedSeats, response.data.usedSeats, response.data.expiresAt ? new Date(response.data.expiresAt) : null); + const expiresAt = response.data.expiresAt ? new Date(response.data.expiresAt) : null; + const gracePeriodEndsAt = response.data.gracePeriodEndsAt ? new Date(response.data.gracePeriodEndsAt) : null; + return new LicenseUserInfoDto(response.data.licensedSeats, response.data.usedSeats, expiresAt, gracePeriodEndsAt); }); } public async installTrial(hubId: string, licenseKey: string): Promise { return axiosAuth.put('/license/trial', { hubId: hubId, licenseKey: licenseKey }) - .then(() => {}) + .then(() => { }) .catch((error) => rethrowAndConvertIfExpected(error, 409)); } diff --git a/frontend/src/components/LicenseAlert.vue b/frontend/src/components/LicenseAlert.vue index 04a64a190..e05b296bd 100644 --- a/frontend/src/components/LicenseAlert.vue +++ b/frontend/src/components/LicenseAlert.vue @@ -1,43 +1,36 @@