Skip to content

Commit

Permalink
Merge pull request #207 from cryptomator/feature/refactored-access-grant
Browse files Browse the repository at this point in the history
refactored access management
  • Loading branch information
overheadhunter authored Aug 22, 2023
2 parents a5e84d0 + 1baf734 commit 465138a
Show file tree
Hide file tree
Showing 59 changed files with 2,714 additions and 1,170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.cryptomator.hub.entities.AuditEventVaultMemberRemove;
import org.cryptomator.hub.entities.AuditEventVaultUpdate;
import org.cryptomator.hub.entities.Device;
import org.cryptomator.hub.entities.VaultAccess;
import org.cryptomator.hub.license.LicenseHolder;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn;
Expand Down Expand Up @@ -107,7 +108,7 @@ static AuditEventDto fromEntity(AuditEvent entity) {
} else if (entity instanceof AuditEventVaultKeyRetrieve evt) {
return new AuditEventVaultKeyRetrieveDto(evt.id, evt.timestamp, AuditEventVaultKeyRetrieve.TYPE, evt.retrievedBy, evt.vaultId, evt.result);
} else if (entity instanceof AuditEventVaultMemberAdd evt) {
return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId);
return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId, evt.role);
} else if (entity instanceof AuditEventVaultMemberRemove evt) {
return new AuditEventVaultMemberRemoveDto(evt.id, evt.timestamp, AuditEventVaultMemberRemove.TYPE, evt.removedBy, evt.vaultId, evt.authorityId);
} else {
Expand Down Expand Up @@ -140,7 +141,7 @@ record AuditEventVaultKeyRetrieveDto(long id, Instant timestamp, String type, @J
}

record AuditEventVaultMemberAddDto(long id, Instant timestamp, String type, @JsonProperty("addedBy") String addedBy, @JsonProperty("vaultId") UUID vaultId,
@JsonProperty("authorityId") String authorityId) implements AuditEventDto {
@JsonProperty("authorityId") String authorityId, @JsonProperty("role") VaultAccess.Role role) implements AuditEventDto {
}

record AuditEventVaultMemberRemoveDto(long id, Instant timestamp, String type, @JsonProperty("removedBy") String removedBy, @JsonProperty("vaultId") UUID vaultId,
Expand Down
11 changes: 6 additions & 5 deletions backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ protected AuthorityDto(String id, Type type, String name, String pictureUrl) {
}

static AuthorityDto fromEntity(Authority a) {
if (a instanceof User u) {
return new UserDto(u.id, u.name, u.pictureUrl, u.email, null);
} else if (a instanceof Group) {
return new GroupDto(a.id, a.name);
// TODO refactor to JEP 441 in JDK 21
if (a instanceof User user) {
return UserDto.justPublicInfo(user);
} else if (a instanceof Group group) {
return GroupDto.fromEntity(group);
} else {
throw new IllegalArgumentException("Authority of this type does not exist");
throw new IllegalStateException("authority is not of type user or group");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public List<AuthorityDto> search(@QueryParam("query") @NotBlank String query) {
@RolesAllowed("admin")
@Produces(MediaType.APPLICATION_JSON)
@NoCache
@Operation(summary = "lists all authorities matching the given ids", description ="lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found")
@Operation(summary = "lists all authorities matching the given ids", description = "lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found")
@APIResponse(responseCode = "200")
public List<AuthorityDto> getSome(@QueryParam("ids") List<String> authorityIds) {
return Authority.findAllInList(authorityIds).map(AuthorityDto::fromEntity).toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
Expand Down Expand Up @@ -51,10 +52,10 @@ public BillingDto get() {
@RolesAllowed("admin")
@Consumes(MediaType.TEXT_PLAIN)
@Operation(summary = "set the token")
@APIResponse(responseCode = "204")
@APIResponse(responseCode = "204", description = "token set")
@APIResponse(responseCode = "400", description = "token is invalid (e.g., expired or invalid signature)")
@APIResponse(responseCode = "403", description = "only admins are allowed to set the token")
public Response setToken(@ValidJWS String token) {
public Response setToken(@NotNull @ValidJWS String token) {
try {
licenseHolder.set(token);
return Response.status(Response.Status.NO_CONTENT).build();
Expand All @@ -69,7 +70,7 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has

public static BillingDto create(String hubId, LicenseHolder licenseHolder) {
var seats = licenseHolder.getNoLicenseSeats();
var remainingSeats = Math.max(seats - EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
var remainingSeats = Math.max(seats - EffectiveVaultAccess.countSeatOccupyingUsers(), 0);
var managedInstance = licenseHolder.isManagedInstance();
return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance);
}
Expand All @@ -78,7 +79,7 @@ public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHol
var id = jwt.getId();
var email = jwt.getSubject();
var totalSeats = jwt.getClaim("seats").asInt();
var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countEffectiveVaultUsers(), 0);
var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countSeatOccupyingUsers(), 0);
var issuedAt = jwt.getIssuedAt().toInstant();
var expiresAt = jwt.getExpiresAt().toInstant();
var managedInstance = licenseHolder.isManagedInstance();
Expand Down
88 changes: 55 additions & 33 deletions backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,49 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.NoResultException;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.cryptomator.hub.entities.Device;
import org.cryptomator.hub.entities.AuditEventDeviceRegister;
import org.cryptomator.hub.entities.AuditEventDeviceRemove;
import org.cryptomator.hub.entities.Device;
import org.cryptomator.hub.entities.LegacyDevice;
import org.cryptomator.hub.entities.User;
import org.cryptomator.hub.validation.NoHtmlOrScriptChars;
import org.cryptomator.hub.validation.OnlyBase64UrlChars;
import org.cryptomator.hub.validation.OnlyBase64Chars;
import org.cryptomator.hub.validation.ValidId;
import org.cryptomator.hub.validation.ValidJWE;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.hibernate.exception.ConstraintViolationException;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;

import java.net.URI;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Set;

@Path("/devices")
public class DeviceResource {

private static final Logger LOG = Logger.getLogger(DeviceResource.class);

@Inject
JsonWebToken jwt;

Expand All @@ -60,25 +66,52 @@ public List<DeviceDto> getSome(@QueryParam("ids") List<String> deviceIds) {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
@Operation(summary = "adds a device", description = "the device will be owned by the currently logged-in user")
@APIResponse(responseCode = "201", description = "device created")
@APIResponse(responseCode = "409", description = "Device already exists")
public Response create(@Valid DeviceDto deviceDto, @PathParam("deviceId") @ValidId String deviceId) {
if (deviceId == null || deviceId.trim().length() == 0 || deviceDto == null) {
return Response.status(Response.Status.BAD_REQUEST).entity("deviceId or deviceDto cannot be empty").build();
@Operation(summary = "creates or updates a device", description = "the device will be owned by the currently logged-in user")
@APIResponse(responseCode = "201", description = "Device created or updated")
@APIResponse(responseCode = "409", description = "Conflicting device name")
public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("deviceId") @ValidId String deviceId) {
Device device;
try {
device = Device.findByIdAndUser(deviceId, jwt.getSubject());
} catch (NoResultException e) {
device = new Device();
device.id = deviceId;
device.owner = User.findById(jwt.getSubject());
device.creationTime = Instant.now().truncatedTo(ChronoUnit.MILLIS);
device.type = dto.type != null ? dto.type : Device.Type.DESKTOP; // default to desktop for backwards compatibility

if (LegacyDevice.deleteById(device.id)) {
assert LegacyDevice.findById(device.id) == null;
LOG.info("Deleted Legacy Device during re-registration of Device " + deviceId);
}
}
User currentUser = User.findById(jwt.getSubject());
var device = deviceDto.toDevice(currentUser, deviceId, Instant.now().truncatedTo(ChronoUnit.MILLIS));
device.name = dto.name;
device.publickey = dto.publicKey;
device.userPrivateKey = dto.userPrivateKey;
try {
device.persistAndFlush();
AuditEventDeviceRegister.log(jwt.getSubject(), deviceId, device.name, device.type);
return Response.created(URI.create(".")).build();
} catch (PersistenceException e) {
if (e instanceof ConstraintViolationException) {
throw new ClientErrorException(Response.Status.CONFLICT, e);
} else {
throw new InternalServerErrorException(e);
}
} catch (ConstraintViolationException e) {
throw new ClientErrorException(Response.Status.CONFLICT, e);
}
}

@GET
@Path("/{deviceId}")
@RolesAllowed("user")
@Produces(MediaType.APPLICATION_JSON)
@NoCache
@Transactional
@Operation(summary = "get the device", description = "the device must be owned by the currently logged-in user")
@APIResponse(responseCode = "200", description = "Device found")
@APIResponse(responseCode = "404", description = "Device not found or owned by a different user")
public DeviceDto get(@PathParam("deviceId") @ValidId String deviceId) {
try {
Device device = Device.findByIdAndUser(deviceId, jwt.getSubject());
return DeviceDto.fromEntity(device);
} catch (NoResultException e) {
throw new NotFoundException(e);
}
}

Expand Down Expand Up @@ -109,24 +142,13 @@ public Response remove(@PathParam("deviceId") @ValidId String deviceId) {
public record DeviceDto(@JsonProperty("id") @ValidId String id,
@JsonProperty("name") @NoHtmlOrScriptChars @NotBlank String name,
@JsonProperty("type") Device.Type type,
@JsonProperty("publicKey") @OnlyBase64UrlChars String publicKey,
@JsonProperty("publicKey") @NotNull @OnlyBase64Chars String publicKey,
@JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKey,
@JsonProperty("owner") @ValidId String ownerId,
@JsonProperty("accessTo") @Valid Set<VaultResource.VaultDto> accessTo,
@JsonProperty("creationTime") Instant creationTime) {

public Device toDevice(User user, String id, Instant creationTime) {
var device = new Device();
device.id = id;
device.owner = user;
device.name = name;
device.type = type != null ? type : Device.Type.DESKTOP; // default to desktop for backwards compatibility
device.publickey = publicKey;
device.creationTime = creationTime;
return device;
}

public static DeviceDto fromEntity(Device entity) {
return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.owner.id, Set.of(), entity.creationTime.truncatedTo(ChronoUnit.MILLIS));
return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.userPrivateKey, entity.owner.id, entity.creationTime.truncatedTo(ChronoUnit.MILLIS));
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;

public class PaymentRequiredException extends ClientErrorException {
class PaymentRequiredException extends ClientErrorException {
public PaymentRequiredException() {
super(Response.Status.PAYMENT_REQUIRED);
}
Expand Down
19 changes: 16 additions & 3 deletions backend/src/main/java/org/cryptomator/hub/api/UserDto.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.cryptomator.hub.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.Nullable;
import org.cryptomator.hub.entities.User;
import org.cryptomator.hub.validation.OnlyBase64Chars;
import org.cryptomator.hub.validation.ValidJWE;

import java.util.Set;

Expand All @@ -11,14 +14,24 @@ public final class UserDto extends AuthorityDto {
public final String email;
@JsonProperty("devices")
public final Set<DeviceResource.DeviceDto> devices;
@JsonProperty("publicKey")
public final String publicKey;
@JsonProperty("privateKey")
public final String privateKey;
@JsonProperty("setupCode")
public final String setupCode;

UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set<DeviceResource.DeviceDto> devices) {
UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set<DeviceResource.DeviceDto> devices,
@Nullable @JsonProperty("publicKey") @OnlyBase64Chars String publicKey, @Nullable @JsonProperty("privateKey") @ValidJWE String privateKey, @Nullable @JsonProperty("setupCode") @ValidJWE String setupCode) {
super(id, Type.USER, name, pictureUrl);
this.email = email;
this.devices = devices;
this.publicKey = publicKey;
this.privateKey = privateKey;
this.setupCode = setupCode;
}

public static UserDto fromEntity(User user) {
return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of());
public static UserDto justPublicInfo(User user) {
return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of(), user.publicKey, null, null);
}
}
31 changes: 18 additions & 13 deletions backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package org.cryptomator.hub.api;

import jakarta.annotation.Nullable;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.cryptomator.hub.entities.AccessToken;
import org.cryptomator.hub.entities.Device;
import org.cryptomator.hub.entities.User;
import org.eclipse.microprofile.jwt.JsonWebToken;
Expand All @@ -35,10 +37,11 @@ public class UsersResource {
@PUT
@Path("/me")
@RolesAllowed("user")
@Consumes(MediaType.APPLICATION_JSON)
@Transactional
@Operation(summary = "sync the logged-in user from the remote user provider to hub")
@Operation(summary = "update the logged-in user")
@APIResponse(responseCode = "201", description = "user created or updated")
public Response syncMe() {
public Response putMe(@Nullable @Valid UserDto dto) {
var userId = jwt.getSubject();
User user = User.findById(userId);
if (user == null) {
Expand All @@ -48,6 +51,11 @@ public Response syncMe() {
user.name = jwt.getName();
user.pictureUrl = jwt.getClaim("picture");
user.email = jwt.getClaim("email");
if (dto != null) {
user.publicKey = dto.publicKey;
user.privateKey = dto.privateKey;
user.setupCode = dto.setupCode;
}
user.persist();
return Response.created(URI.create(".")).build();
}
Expand All @@ -59,16 +67,13 @@ public Response syncMe() {
@NoCache
@Transactional
@Operation(summary = "get the logged-in user")
public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam("withAccessibleVaults") boolean withAccessibleVaults) {
@APIResponse(responseCode = "200", description = "returns the current user")
@APIResponse(responseCode = "404", description = "no user matching the subject of the JWT passed as Bearer Token")
public UserDto getMe(@QueryParam("withDevices") boolean withDevices) {
User user = User.findById(jwt.getSubject());
Function<AccessToken, VaultResource.VaultDto> mapAccessibleVaults =
a -> new VaultResource.VaultDto(a.vault.id, a.vault.name, a.vault.description, a.vault.archived, a.vault.creationTime.truncatedTo(ChronoUnit.MILLIS), null, 0, null, null, null);
Function<Device, DeviceResource.DeviceDto> mapDevices = withAccessibleVaults //
? d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, d.accessTokens.stream().map(mapAccessibleVaults).collect(Collectors.toSet()), d.creationTime.truncatedTo(ChronoUnit.MILLIS)) //
: d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, Set.of(), d.creationTime.truncatedTo(ChronoUnit.MILLIS));
return withDevices //
? new UserDto(user.id, user.name, user.pictureUrl, user.email, user.devices.stream().map(mapDevices).collect(Collectors.toSet()))
: new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of());
Function<Device, DeviceResource.DeviceDto> mapDevices = d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.userPrivateKey, d.owner.id, d.creationTime.truncatedTo(ChronoUnit.MILLIS));
var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.<DeviceResource.DeviceDto>of();
return new UserDto(user.id, user.name, user.pictureUrl, user.email, devices, user.publicKey, user.privateKey, user.setupCode);
}

@GET
Expand All @@ -77,7 +82,7 @@ public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "list all users")
public List<UserDto> getAll() {
return User.findAll().<User>stream().map(UserDto::fromEntity).toList();
return User.findAll().<User>stream().map(UserDto::justPublicInfo).toList();
}

}
Loading

0 comments on commit 465138a

Please sign in to comment.