Skip to content

Commit

Permalink
Merge pull request #174 from cryptomator/feature/support-managed-inst…
Browse files Browse the repository at this point in the history
…ances

Use ENV variable to distinguish between self-hosted and managed instance
  • Loading branch information
tobihagemann authored Jan 9, 2023
2 parents eac26df + 9a7e1d4 commit 65f6be2
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 28 deletions.
19 changes: 10 additions & 9 deletions backend/src/main/java/org/cryptomator/hub/api/BillingResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
@Path("/billing")
public class BillingResource {


@Inject
LicenseHolder licenseHolder;

Expand All @@ -40,10 +39,10 @@ public class BillingResource {
@APIResponse(responseCode = "403", description = "only admins are allowed to get the billing information")
public BillingDto get() {
return Optional.ofNullable(licenseHolder.get())
.map(BillingDto::fromDecodedJwt)
.map(jwt -> BillingDto.fromDecodedJwt(jwt, licenseHolder))
.orElseGet(() -> {
var hubId = Settings.get().hubId;
return BillingDto.create(hubId);
return BillingDto.create(hubId, licenseHolder);
});
}

Expand All @@ -66,22 +65,24 @@ public Response setToken(@ValidJWS String token) {

public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("hasLicense") Boolean hasLicense, @JsonProperty("email") String email,
@JsonProperty("totalSeats") Integer totalSeats, @JsonProperty("remainingSeats") Integer remainingSeats,
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt) {
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) {

public static BillingDto create(String hubId) {
var seats = LicenseHolder.CommunityLicenseConstants.SEATS;
public static BillingDto create(String hubId, LicenseHolder licenseHolder) {
var seats = licenseHolder.getNoLicenseSeats();
var remainingSeats = Math.max(seats - EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null);
var managedInstance = licenseHolder.isManagedInstance();
return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance);
}

public static BillingDto fromDecodedJwt(DecodedJWT jwt) {
public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHolder) {
var id = jwt.getId();
var email = jwt.getSubject();
var totalSeats = jwt.getClaim("seats").asInt();
var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
var issuedAt = jwt.getIssuedAt().toInstant();
var expiresAt = jwt.getExpiresAt().toInstant();
return new BillingDto(id, true, email, totalSeats, remainingSeats, issuedAt, expiresAt);
var managedInstance = licenseHolder.isManagedInstance();
return new BillingDto(id, true, email, totalSeats, remainingSeats, issuedAt, expiresAt, managedInstance);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.cryptomator.hub.entities.Settings;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.Objects;
Expand All @@ -16,6 +18,10 @@
@ApplicationScoped
public class LicenseHolder {

@Inject
@ConfigProperty(name = "hub.managed-instance", defaultValue = "false")
Boolean managedInstance;

private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
private final LicenseValidator licenseValidator;
private DecodedJWT license;
Expand Down Expand Up @@ -69,26 +75,45 @@ public DecodedJWT get() {
public boolean isExpired() {
return Optional.ofNullable(license) //
.map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) //
.orElse(CommunityLicenseConstants.IS_EXPIRED);
.orElse(false);
}

/**
* Gets the number of available seats of the license
*
* @return Number of available seats, if license is not null. Otherwise {@value CommunityLicenseConstants#SEATS}.
* @return Number of available seats, if license is not null. Otherwise {@value SelfHostedNoLicenseConstants#SEATS}.
*/
public long getAvailableSeats() {
return Optional.ofNullable(license) //
.map(l -> l.getClaim("seats")) //
.map(Claim::asLong) //
.orElse(CommunityLicenseConstants.SEATS);
.orElseGet(this::getNoLicenseSeats);
}

public long getNoLicenseSeats() {
if (!managedInstance) {
return SelfHostedNoLicenseConstants.SEATS;
} else {
return ManagedInstanceNoLicenseConstants.SEATS;
}
}

public boolean isManagedInstance() {
return managedInstance;
}

public static class CommunityLicenseConstants {
public static class SelfHostedNoLicenseConstants {
public static final long SEATS = 5;
static final boolean IS_EXPIRED = false;

private CommunityLicenseConstants() {
private SelfHostedNoLicenseConstants() {
throw new IllegalStateException("Utility class");
}
}

public static class ManagedInstanceNoLicenseConstants {
public static final long SEATS = 0;

private ManagedInstanceNoLicenseConstants() {
throw new IllegalStateException("Utility class");
}
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ hub.keycloak.public-url=http://localhost:8180
hub.keycloak.local-url=http://localhost:8180
hub.keycloak.realm=cryptomator

hub.managed-instance=false

quarkus.resteasy-reactive.path=/api
%test.quarkus.resteasy-reactive.path=/

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.cryptomator.hub.api;

import com.radcortez.flyway.test.annotation.DataSource;
import com.radcortez.flyway.test.annotation.FlywayTest;
import io.agroal.api.AgroalDataSource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.Claim;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
import java.sql.SQLException;
import java.util.Map;

import static io.restassured.RestAssured.when;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;

@QuarkusTest
@FlywayTest(value = @DataSource(url = "jdbc:h2:mem:test"), additionalLocations = {"classpath:org/cryptomator/hub/flyway"})
@DisplayName("Resource /billing managed instance")
@TestSecurity(user = "Admin", roles = {"admin"})
@OidcSecurity(claims = {
@Claim(key = "sub", value = "admin")
})
@TestProfile(BillingResourceManagedInstanceTest.ManagedInstanceTestProfile.class)
public class BillingResourceManagedInstanceTest {

@Inject
AgroalDataSource dataSource;

@BeforeAll
public static void beforeAll() {
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}

public static class ManagedInstanceTestProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("hub.managed-instance", "true");
}
}

@Test
@DisplayName("GET /billing returns 401 with empty license managed instance")
public void testGetEmptyManagedInstance() throws SQLException {
try (var s = dataSource.getConnection().createStatement()) {
s.execute("""
UPDATE "settings"
SET "hub_id" = '42', "license_key" = null
WHERE "id" = 0;
""");
}

when().get("/billing")
.then().statusCode(200)
.body("hubId", is("42"))
.body("hasLicense", is(false))
.body("email", nullValue())
.body("totalSeats", is(0))
.body("remainingSeats", is(0))
.body("issuedAt", nullValue())
.body("expiresAt", nullValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ public class AsAdmin {

@Test
@Order(1)
@DisplayName("GET /billing returns 200 with empty license")
public void testGetEmpty() throws SQLException {
@DisplayName("GET /billing returns 200 with empty license self-hosted")
public void testGetEmptySelfHosted() throws SQLException {
try (var s = dataSource.getConnection().createStatement()) {
s.execute("""
UPDATE "settings"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export type BillingDto = {
remainingSeats: number;
issuedAt: Date;
expiresAt: Date;
managedInstance: boolean;
}

export type VersionDto = {
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/components/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,19 @@
<h3 class="text-lg font-medium leading-6 text-gray-900">
{{ t('admin.licenseInfo.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ t('admin.licenseInfo.communityLicense.description') }}
<p v-if="!admin.managedInstance" class="mt-1 text-sm text-gray-500">
{{ t('admin.licenseInfo.selfHostedNoLicense.description') }}
</p>
<p v-else class="mt-1 text-sm text-gray-500">
{{ t('admin.licenseInfo.managedNoLicense.description') }}
</p>
</div>
<div class="mt-5 md:mt-0 md:col-span-2">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-3">
<label for="licenseType" class="block text-sm font-medium text-gray-700">{{ t('admin.licenseInfo.communityLicense.type.title') }}</label>
<input id="licenseType" value="Community License" type="text" class="mt-1 focus:ring-primary focus:border-primary block w-full shadow-sm sm:text-sm border-gray-300 rounded-md bg-gray-200" readonly />
<label for="licenseType" class="block text-sm font-medium text-gray-700">{{ t('admin.licenseInfo.type.title') }}</label>
<input v-if="!admin.managedInstance" id="licenseType" value="Community License" type="text" class="mt-1 focus:ring-primary focus:border-primary block w-full shadow-sm sm:text-sm border-gray-300 rounded-md bg-gray-200" readonly />
<input v-else id="licenseType" value="Managed" type="text" class="mt-1 focus:ring-primary focus:border-primary block w-full shadow-sm sm:text-sm border-gray-300 rounded-md bg-gray-200" readonly />
</div>

<div class="col-span-6 sm:col-span-3">
Expand All @@ -179,7 +183,7 @@
<div class="flex justify-end items-center px-4 py-3 bg-gray-50 sm:px-6">
<button type="button" class="flex-none inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-d1 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:hover:bg-primary disabled:cursor-not-allowed" @click="manageSubscription()">
<ArrowTopRightOnSquareIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{{ t('admin.licenseInfo.communityLicense.upgradeLicense') }}
{{ t('admin.licenseInfo.getLicense') }}
</button>
</div>
</div>
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/i18n/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@
"admin.licenseInfo.expiresAt.description.valid": "Deine Lizenz ist gültig.",
"admin.licenseInfo.expiresAt.description.expired": "Deine Lizenz ist abgelaufen.",
"admin.licenseInfo.manageSubscription": "Abonnement verwalten",
"admin.licenseInfo.communityLicense.description": "Vielen Dank, dass du Cryptomator Hub nutzt! Du hast die Community-Lizenz erhalten. Wenn du mehr Sitze benötigst, erweiter deine Lizenz.",
"admin.licenseInfo.communityLicense.type.title": "Typ",
"admin.licenseInfo.communityLicense.upgradeLicense": "Lizenz erhalten",
"admin.licenseInfo.type.title": "Typ",
"admin.licenseInfo.getLicense": "Lizenz erhalten",
"admin.licenseInfo.selfHostedNoLicense.description": "Vielen Dank, dass du Cryptomator Hub nutzt! Du hast die Community-Lizenz erhalten. Wenn du mehr Sitze benötigst, erweiter deine Lizenz.",
"admin.licenseInfo.managedNoLicense.description": "Vielen Dank, dass du Cryptomator Hub nutzt! Du hast aktuell keine aktive Lizenz.",

"authenticateVaultAdminDialog.title": "Tresor verwalten",
"authenticateVaultAdminDialog.description": "Gib das Vault-Admin-Passwort des Tresors ein, um ihn zu verwalten.",
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@
"admin.licenseInfo.expiresAt.description.valid": "Your license is valid.",
"admin.licenseInfo.expiresAt.description.expired": "Your license has expired.",
"admin.licenseInfo.manageSubscription": "Manage Subscription",
"admin.licenseInfo.communityLicense.description": "Thank you for using Cryptomator Hub! You have been granted the Community License. If you need more seats, upgrade your license.",
"admin.licenseInfo.communityLicense.type.title": "Type",
"admin.licenseInfo.communityLicense.upgradeLicense": "Get License",
"admin.licenseInfo.type.title": "Type",
"admin.licenseInfo.getLicense": "Get License",
"admin.licenseInfo.selfHostedNoLicense.description": "Thank you for using Cryptomator Hub! You have been granted the Community License. If you need more seats, upgrade your license.",
"admin.licenseInfo.managedNoLicense.description": "Thank you for using Cryptomator Hub! You currently have no active license.",

"authenticateVaultAdminDialog.title": "Manage Vault",
"authenticateVaultAdminDialog.description": "Type in the vault admin password to manage it.",
Expand Down

0 comments on commit 65f6be2

Please sign in to comment.