Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Comment thread
SailReal marked this conversation as resolved.

private final Boolean managedInstance;
private final Optional<String> initialId;
private final Optional<String> initialLicenseToken;
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@
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;
import org.junit.jupiter.api.Nested;
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")
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
}
}

}
19 changes: 13 additions & 6 deletions frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -652,13 +657,15 @@ class LicenseService {

public async getUserInfo(): Promise<LicenseUserInfoDto> {
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<void> {
return axiosAuth.put('/license/trial', { hubId: hubId, licenseKey: licenseKey })
.then(() => {})
.then(() => { })
.catch((error) => rethrowAndConvertIfExpected(error, 409));
}

Expand Down
59 changes: 26 additions & 33 deletions frontend/src/components/LicenseAlert.vue
Original file line number Diff line number Diff line change
@@ -1,43 +1,36 @@
<template>
<div v-if="props.licenseStatus.isExpired()" class="rounded-md bg-red-50 p-4 mb-3">
<div class="flex">
<div class="shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">{{ t('licenseAlert.licenseExpired.title') }}</h3>
<i18n-t v-if="props.isAdmin" keypath="licenseAlert.licenseExpired.admin.description" scope="global" tag="p" class="mt-2 text-sm text-red-700">
<router-link to="/app/admin/settings" class="text-sm text-red-700 underline hover:text-red-600">
{{ t('licenseAlert.button') }}
</router-link>
</i18n-t>
<p v-else class="mt-2 text-sm text-red-700">{{ t('licenseAlert.licenseExpired.user.description') }}</p>
</div>
</div>
</div>
<ContentBanner v-if="props.licenseStatus.isExpired('allowGracePeriod')" type="error" :title="t('licenseAlert.licenseExpired.title')" class="mb-4">
<i18n-t v-if="props.isAdmin" keypath="licenseAlert.licenseExpired.admin.description" scope="global" tag="p">
<router-link to="/app/admin/settings" class="underline hover:no-underline">
{{ t('licenseAlert.button') }}
</router-link>
</i18n-t>
<p v-else>{{ t('licenseAlert.licenseExpired.user.description') }}</p>
</ContentBanner>

<div v-else-if="props.licenseStatus.isExceeded()" class="rounded-md bg-yellow-50 p-4 mb-3">
<div class="flex">
<div class="shrink-0">
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">{{ t('licenseAlert.noRemainingSeats.title') }}</h3>
<i18n-t v-if="props.isAdmin" keypath="licenseAlert.noRemainingSeats.admin.description" scope="global" tag="p" class="mt-2 text-sm text-yellow-700">
<router-link to="/app/admin/settings" class="text-sm text-yellow-700 underline hover:text-yellow-600">
{{ t('licenseAlert.button') }}
</router-link>
</i18n-t>
<p v-else class="mt-2 text-sm text-yellow-700">{{ t('licenseAlert.noRemainingSeats.user.description') }}</p>
</div>
</div>
</div>
<ContentBanner v-else-if="props.licenseStatus.isExceeded()" type="warning" :title="t('licenseAlert.noRemainingSeats.title')" class="mb-4">
<i18n-t v-if="props.isAdmin" keypath="licenseAlert.noRemainingSeats.admin.description" scope="global" tag="p">
<router-link to="/app/admin/settings" class="underline hover:no-underline">
{{ t('licenseAlert.button') }}
</router-link>
</i18n-t>
<p v-else>{{ t('licenseAlert.noRemainingSeats.user.description') }}</p>
</ContentBanner>

<ContentBanner v-else-if="props.licenseStatus.isExpired()" type="warning" :title="t('licenseAlert.licenseExpiredGracePeriod.title')" class="mb-4">
<i18n-t v-if="props.isAdmin" keypath="licenseAlert.licenseExpiredGracePeriod.admin.description" scope="global" tag="p">
<router-link to="/app/admin/settings" class="underline hover:no-underline">
{{ t('licenseAlert.button') }}
</router-link>
</i18n-t>
<p v-else>{{ t('licenseAlert.licenseExpiredGracePeriod.user.description') }}</p>
</ContentBanner>
</template>

<script setup lang="ts">
import { ExclamationTriangleIcon, XCircleIcon } from '@heroicons/vue/20/solid';
import { useI18n } from 'vue-i18n';
import { LicenseUserInfoDto } from '../common/backend';
import ContentBanner from './ContentBanner.vue';

const { t } = useI18n({ useScope: 'global' });

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/VaultDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ const grantEmergencyAccessDialog = ref<typeof GrantEmergencyAccessDialog>();
const vaultRecoveryRequired = ref<boolean>(false);

const isLegacyVault = computed(() => vault.value?.authPublicKey !== undefined);
const licenseViolated = computed(() => license.value?.isExpired() || license.value?.isExceeded());
const licenseViolated = computed(() => license.value?.isViolated() ?? false);

const emergencyKeyShareAuthorities = ref<Record<string, AuthorityDto>>({});

Expand Down
10 changes: 2 additions & 8 deletions frontend/src/components/VaultList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</div>
</div>

<LicenseAlert v-if="isLicenseViolated && licenseStatus" :is-admin="isAdmin" :license-status="licenseStatus" />
<LicenseAlert v-if="licenseStatus" :is-admin="isAdmin" :license-status="licenseStatus" />

<ContentBanner v-if="anyUserHasLegacyDevices" type="warning" :title="t('legacyDeviceBanner.title')" class="mb-4">
{{ t('legacyDeviceBanner.admin.description') }}
Expand Down Expand Up @@ -180,13 +180,7 @@ const canCreateVaults = ref<boolean>(false);
const hasLegacyDevices = ref<boolean>(false);
const anyUserHasLegacyDevices = ref<boolean>(false);
const licenseStatus = ref<LicenseUserInfoDto>();
const isLicenseViolated = computed(() => {
if (licenseStatus.value) {
return licenseStatus.value.isExceeded() || licenseStatus.value.isExpired();
} else {
return false;
}
});
const isLicenseViolated = computed(() => licenseStatus.value?.isViolated() ?? false);

const isCommunityLicense = computed(() => {
return !licenseStatus.value?.expiresAt;
Expand Down
Loading