From 85f53a94b00abb3955b3096446bb34e3a4fe488c Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Tue, 28 Jul 2026 10:33:21 +0200 Subject: [PATCH 1/2] Add license leeway --- CHANGELOG.md | 1 + .../cryptomator/hub/api/LicenseResource.java | 10 ++- .../hub/license/LicenseHolder.java | 32 ++++++- .../hub/api/LicenseResourceTest.java | 36 ++++++++ .../hub/license/LicenseHolderTest.java | 57 +++++++++++++ frontend/src/common/backend.ts | 18 +++- frontend/src/components/LicenseAlert.vue | 59 ++++++------- frontend/src/components/VaultDetails.vue | 2 +- frontend/src/components/VaultList.vue | 10 +-- .../EmergencyAccessVaultList.vue | 9 +- frontend/src/i18n/en-US.json | 3 + frontend/test/common/backend.spec.ts | 84 ++++++++++++++++++- 12 files changed, 259 insertions(+), 62 deletions(-) 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..b6519d8dc 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 expiration leeway.") @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("leewayEndsAt") Instant leewayEndsAt) { 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 leewayEndsAt = licenseHolder.getLeewayEndsAt(); + return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt, leewayEndsAt); } } 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..aa6e988e0 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 EXPIRATION_LEEWAY = 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 #EXPIRATION_LEEWAY} + */ + public Instant getLeewayEndsAt() { + return getExpiresAt().plus(EXPIRATION_LEEWAY); + } + + /** + * Checks if the license is expired, granting a grace period of {@link #EXPIRATION_LEEWAY}. * - * @return {@code true}, if the license expired, {@code false} otherwise. + * @return {@code true}, if the license expired more than {@link #EXPIRATION_LEEWAY} ago, {@code false} otherwise. */ public boolean isExpired() { - return get().getExpiresAt().toInstant().isBefore(Instant.now()); + return getLeewayEndsAt().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..162567012 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 leeway 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).getLeewayEndsAt(); + 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("leewayEndsAt", 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..8ba9a1947 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 leeway is not considered expired") + void testExpiredWithinLeeway() { + Mockito.doReturn(Instant.now().minus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertFalse(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("license expired almost beyond leeway is not considered expired") + void testExpiredAlmostBeyondLeeway() { + Mockito.doReturn(Instant.now().minus(LicenseHolder.EXPIRATION_LEEWAY).plus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertFalse(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("license expired beyond leeway is considered expired") + void testExpiredBeyondLeeway() { + Mockito.doReturn(Instant.now().minus(LicenseHolder.EXPIRATION_LEEWAY).minus(1, ChronoUnit.HOURS)).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertTrue(licenseHolderSpy.isExpired()); + } + + @Test + @DisplayName("leeway ends 9 days after the expiry date") + void testLeewayEndsAt() { + Mockito.doReturn(Instant.parse("2026-07-27T00:00:00Z")).when(licenseJwt).getExpiresAtAsInstant(); + + Assertions.assertEquals(Instant.parse("2026-08-05T00:00:00Z"), licenseHolderSpy.getLeewayEndsAt()); + } + } + } diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index bbbb454ab..7b16a781d 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -229,18 +229,26 @@ export class LicenseUserInfoDto { constructor( public licensedSeats: number, public usedSeats: number, - public expiresAt: Date | null) { + public expiresAt: Date | null, + public leewayEndsAt: Date | null) { } public isExpired(): boolean { - const now = new Date(); - return now > (this.expiresAt ?? now); //if expired is null, the license cannot expire + return this.leewayEndsAt != null && new Date() > this.leewayEndsAt; // no leeway end means no expiration date, i.e. the license cannot expire + } + + public isExpiredWithinLeeway(): boolean { + return this.expiresAt != null && new Date() > this.expiresAt && !this.isExpired(); } public isExceeded(): boolean { return this.licensedSeats == 0 || this.usedSeats > this.licensedSeats; } + public isViolated(): boolean { + return this.isExpired() || this.isExceeded(); + } + } export interface VaultIdHeader extends JWTHeader { @@ -652,7 +660,9 @@ 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 leewayEndsAt = response.data.leewayEndsAt ? new Date(response.data.leewayEndsAt) : null; + return new LicenseUserInfoDto(response.data.licensedSeats, response.data.usedSeats, expiresAt, leewayEndsAt); }); } diff --git a/frontend/src/components/LicenseAlert.vue b/frontend/src/components/LicenseAlert.vue index 04a64a190..a0d479bae 100644 --- a/frontend/src/components/LicenseAlert.vue +++ b/frontend/src/components/LicenseAlert.vue @@ -1,43 +1,36 @@