Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- User and group management (#376)
- Emergency Access: Allow a council to restore access to a orphaned vault (#390)
- Show pictures of the groups in the Vaults member list (#375)
- Disable users to exclude them from license seat count (#427)
- Allow admins to archive and unarchive any vault (#283, #430)
- Disable users to exclude them from license seat count (#427, #428)

### Changed

Expand Down
21 changes: 20 additions & 1 deletion backend/src/main/java/org/cryptomator/hub/api/VaultResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map<St
@GET
@Path("/{vaultId}")
@RolesAllowed("user")
@VaultRole(value = {VaultAccess.Role.MEMBER, VaultAccess.Role.OWNER}, bypassForRealmRole = true, realmRole = RealmRole.ADMIN, onMissingVault = VaultRole.OnMissingVault.NOT_FOUND)
@VaultRole(value = {VaultAccess.Role.MEMBER, VaultAccess.Role.OWNER}, bypassForRealmRole = true, onMissingVault = VaultRole.OnMissingVault.NOT_FOUND)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
@Operation(summary = "gets a vault")
Expand All @@ -512,6 +512,25 @@ public VaultDto get(@PathParam("vaultId") UUID vaultId) {
return VaultDto.fromEntity(vault);
}

@PUT
@Path("/{vaultId}/archived")
@RolesAllowed("user")
@VaultRole(value = VaultAccess.Role.OWNER, bypassForRealmRole = true, onMissingVault = VaultRole.OnMissingVault.NOT_FOUND)
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
@Operation(summary = "sets the archived flag of a vault")
@APIResponse(responseCode = "200", description = "archived flag updated")
@APIResponse(responseCode = "403", description = "requesting user is neither a vault owner nor has the admin role")
@APIResponse(responseCode = "404", description = "vault not found")
public VaultDto setArchived(@PathParam("vaultId") UUID vaultId, @NotNull Boolean archived) {
Vault vault = vaultRepo.findByIdOptional(vaultId).orElseThrow(NotFoundException::new);
vault.setArchived(archived);
Comment thread
SailReal marked this conversation as resolved.
vaultRepo.persistAndFlush(vault);
eventLogger.logVaultUpdated(jwt.getSubject(), vault.getId(), vault.getName(), vault.getDescription(), vault.isArchived());
return VaultDto.fromEntity(vault);
}

@PUT
@Path("/{vaultId}")
@RolesAllowed("user") // general authentication. VaultRole filter will check for specific access rights
Expand Down
15 changes: 13 additions & 2 deletions backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,27 @@
enum OnMissingVault {FORBIDDEN, NOT_FOUND, PASS, REQUIRE_REALM_ROLE}

/**
* If set to true, skip the role check if the current user has the role {@link #realmRole()}.
* If set to true, skip the role check if the current user has the role {@link #bypassRealmRole()}.
* <p>
* Only applies when the vault exists. For non-existing vaults, use {@link #onMissingVault()} instead.
*
* @return whether the given realm role allows bypassing the vault role check.
*/
boolean bypassForRealmRole() default false;

/**
* Which realm role bypasses the vault role check when {@link #bypassForRealmRole()} is true.
* <p>
* Separate from {@link #realmRole()}, which is only used for {@link OnMissingVault#REQUIRE_REALM_ROLE}.
*
* @return realm role that bypasses the vault role check.
*/
RealmRole bypassRealmRole() default RealmRole.ADMIN;

/**
* Which additional realm role is required to access the annotated resource.
* <p>
* Only relevant if {@link #bypassForRealmRole()} or {@link #onMissingVault()} is set to {@link OnMissingVault#REQUIRE_REALM_ROLE}.
* Only relevant if {@link #onMissingVault()} is set to {@link OnMissingVault#REQUIRE_REALM_ROLE}.
*
* @return realm role required to access the annotated resource.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,6 @@ public class VaultRoleFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext requestContext) throws NotFoundException, ForbiddenException, NotAuthorizedException {
var annotation = resourceInfo.getResourceMethod().getAnnotation(VaultRole.class);
if (annotation.bypassForRealmRole() && requestContext.getSecurityContext().isUserInRole(annotation.realmRole().kcName())) {
// user has required realm role, so we skip the vault role check:
return;
}

var vaultIdStr = requestContext.getUriInfo().getPathParameters().getFirst(annotation.vaultIdParam());
final UUID vaultId;
try {
Expand All @@ -67,22 +62,25 @@ public void filter(ContainerRequestContext requestContext) throws NotFoundExcept
}

var vault = vaultRepo.findById(vaultId);
if (vault != null && annotation.bypassForEmergencyAccess() && isEmergencyAccessCouncilMember(userId, vault)) {
// user is a member of the emergency access council, so we skip the role check:
return;
}

var forbiddenMsg = "Vault role required: " + Arrays.stream(annotation.value()).map(VaultAccess.Role::name).collect(Collectors.joining(", "));
if (vault != null) {
if (annotation.bypassForRealmRole() && requestContext.getSecurityContext().isUserInRole(annotation.bypassRealmRole().kcName())) {
// user has required realm role, so we skip the vault role check:
return;
}
if (annotation.bypassForEmergencyAccess() && isEmergencyAccessCouncilMember(userId, vault)) {
// user is a member of the emergency access council, so we skip the role check:
return;
}
// check permissions for existing vault:
var effectiveRoles = effectiveVaultAccessRepo.listRoles(vaultId, userId);
if (Arrays.stream(annotation.value()).noneMatch(effectiveRoles::contains)) {
throw new ForbiddenException(forbiddenMsg);
var requiredRoles = annotation.value();
if (Arrays.stream(requiredRoles).noneMatch(effectiveRoles::contains)) {
throw new ForbiddenException("Vault role required: " + Arrays.stream(requiredRoles).map(VaultAccess.Role::name).collect(Collectors.joining(", ")));
Comment thread
SailReal marked this conversation as resolved.
}
} else {
// how to treat non-existing vault:
switch (annotation.onMissingVault()) {
case FORBIDDEN -> throw new ForbiddenException(forbiddenMsg);
case FORBIDDEN -> throw new ForbiddenException("Vault role required: " + Arrays.stream(annotation.value()).map(VaultAccess.Role::name).collect(Collectors.joining(", ")));
Comment thread
SailReal marked this conversation as resolved.
case NOT_FOUND -> throw new NotFoundException("Vault not found");
case PASS -> {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,67 @@ void cleanup() throws SQLException {

}

@Nested
@DisplayName("As admin user2 (non-owner)")
@TestSecurity(user = "User Name 2", roles = {"user", "admin"})
@OidcSecurity(claims = {
@Claim(key = "sub", value = "user2")
})
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AdminArchiveVault {

@Test
@Order(1)
@DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/archived returns 200 for admin archiving vault")
@DBRollbackAfter
void testAdminArchiveVault() {
given().contentType(ContentType.TEXT).body("true")
.when().put("/vaults/{vaultId}/archived", "7E57C0DE-0000-4000-8000-000100001111")
.then().statusCode(200)
.body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"))
.body("name", equalTo("Vault 1"))
.body("archived", equalTo(true));
}

@Test
@Order(2)
@DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/archived returns 200 for admin unarchiving vault")
@DBRollbackAfter
void testAdminUnarchiveVault() {
given().contentType(ContentType.TEXT).body("false")
.when().put("/vaults/{vaultId}/archived", "7E57C0DE-0000-4000-8000-00010000AAAA")
.then().statusCode(200)
.body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-00010000AAAA"))
.body("name", equalTo("Vault Archived"))
.body("archived", equalTo(false));
}

@Test
@Order(3)
@DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111 returns 403 for admin updating vault they don't own")
void testAdminCannotUpdateVaultTheyDontOwn() {
var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100001111");
var vaultDto = new VaultResource.VaultDto(uuid, "Vault 1", Instant.parse("2020-02-20T20:20:20Z"), "This is a testvault.", true, 0, Map.of(), "masterkey1", 42, "salt1", "authPubKey1", "authPrvKey1");

given().contentType(ContentType.JSON).body(vaultDto)
.when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100001111")
.then().statusCode(403);
}

@Test
@Order(4)
@DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100005555 returns 403 for admin creating vault without create-vaults role")
void testAdminCannotCreateVaultWithoutCreateVaultsRole() {
var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100005555");
var vaultDto = new VaultResource.VaultDto(uuid, "New Vault", Instant.parse("2112-12-21T21:12:21Z"), "Should not be created", false, 0, Map.of(), "masterkey5", 42, "NaCl", "authPubKey5", "authPrvKey5");

given().contentType(ContentType.JSON).body(vaultDto)
.when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100005555")
.then().statusCode(403);
}

}

@Nested
@DisplayName("As unauthenticated user")
class AsAnonymous {
Expand All @@ -976,7 +1037,8 @@ class AsAnonymous {
"PUT, /vaults/7E57C0DE-0000-4000-8000-000100001111/users/user1",
"DELETE, /vaults/7E57C0DE-0000-4000-8000-000100001111/authority/user1",
"GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant",
"GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token"
"GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token",
"PUT, /vaults/7E57C0DE-0000-4000-8000-000100001111/archived"
})
void testGet(String method, String path) {
when().request(method, path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,35 @@ void testFilterSuccess4() throws NoSuchMethodException {
Mockito.verify(effectiveVaultAccessRepo, Mockito.never()).listRoles(Mockito.any(), Mockito.any());
}

@Test
@DisplayName("pass if admin user tries to access 7E57C0DE-0000-4000-8000-000100001111 (admin bypasses vault role check)")
void testFilterBypassForAdmin() throws NoSuchMethodException {
Mockito.doReturn(VaultRoleFilterTest.class.getMethod("byPassForRealmRole")).when(resourceInfo).getResourceMethod();
Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100001111"))).when(uriInfo).getPathParameters();
Mockito.doReturn("user2").when(jwt).getSubject();
Mockito.when(vaultRepo.findById(uuid("7E57C0DE-0000-4000-8000-000100001111"))).thenReturn(Mockito.mock(Vault.class));
Mockito.doReturn(true).when(securityContext).isUserInRole("admin");

Assertions.assertDoesNotThrow(() -> filter.filter(context));

Mockito.verify(effectiveVaultAccessRepo, Mockito.never()).listRoles(Mockito.any(), Mockito.any());
}

@Test
@DisplayName("error 403 if non-admin user tries to access 7E57C0DE-0000-4000-8000-000100001111 (bypassForRealmRole has no effect)")
void testFilterNoBypassForNonAdmin() throws NoSuchMethodException {
Mockito.doReturn(VaultRoleFilterTest.class.getMethod("byPassForRealmRole")).when(resourceInfo).getResourceMethod();
Mockito.doReturn(new MultivaluedHashMap<>(Map.of(VaultRole.DEFAULT_VAULT_ID_PARAM, "7E57C0DE-0000-4000-8000-000100001111"))).when(uriInfo).getPathParameters();
Mockito.doReturn("user2").when(jwt).getSubject();
Mockito.when(vaultRepo.findById(uuid("7E57C0DE-0000-4000-8000-000100001111"))).thenReturn(Mockito.mock(Vault.class));
Mockito.when(effectiveVaultAccessRepo.listRoles(uuid("7E57C0DE-0000-4000-8000-000100001111"), Mockito.eq("user2"))).thenReturn(Set.of());
Mockito.doReturn(false).when(securityContext).isUserInRole("admin");

var e = Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context));

Assertions.assertEquals("Vault role required: OWNER", e.getMessage());
}

@Nested
@DisplayName("when attempting to access archived vault")
class OnArchivedVault {
Expand Down Expand Up @@ -239,6 +268,35 @@ void testHasRole() {

}

@Nested
@DisplayName("if @VaultRole(bypassForRealmRole = true, onMissingVault = REQUIRE_REALM_ROLE, realmRole = CREATE_VAULTS)")
class BypassRealmRoleWithRequireRealmRole {

@BeforeEach
void setup() throws NoSuchMethodException {
Mockito.doReturn(NonExistingVault.class.getMethod("bypassRealmRoleWithRequireRealmRole")).when(resourceInfo).getResourceMethod();
}

@Test
@DisplayName("error 403 if admin lacks create-vaults role (bypass does not apply for non-existing vault)")
void testAdminWithoutCreateVaultsRole() {
Mockito.doReturn(true).when(securityContext).isUserInRole("admin");
Mockito.doReturn(false).when(securityContext).isUserInRole("create-vaults");

Assertions.assertThrows(ForbiddenException.class, () -> filter.filter(context));
}

@Test
@DisplayName("pass if user has create-vaults role (onMissingVault checks realmRole, not bypassRealmRole)")
void testUserWithCreateVaultsRole() {
Mockito.doReturn(false).when(securityContext).isUserInRole("admin");
Mockito.doReturn(true).when(securityContext).isUserInRole("create-vaults");

Assertions.assertDoesNotThrow(() -> filter.filter(context));
}

}

}

/*
Expand All @@ -249,6 +307,10 @@ void testHasRole() {
public void byPassRecoveryCouncilMembers() {
}

@VaultRole(value = {VaultAccess.Role.OWNER}, bypassForRealmRole = true)
public void byPassForRealmRole() {
}

@VaultRole({VaultAccess.Role.MEMBER})
public void allowMember() {
}
Expand All @@ -270,6 +332,10 @@ public void notFound() {
public void pass() {
}

@VaultRole(value = {VaultAccess.Role.OWNER}, bypassForRealmRole = true, onMissingVault = VaultRole.OnMissingVault.REQUIRE_REALM_ROLE, realmRole = RealmRole.CREATE_VAULTS)
public void bypassRealmRoleWithRequireRealmRole() {
}

@VaultRole(value = {VaultAccess.Role.OWNER}, onMissingVault = VaultRole.OnMissingVault.REQUIRE_REALM_ROLE, realmRole = RealmRole.ADMIN)
public void requireRealmRole() {
}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,15 @@ class VaultService {
return addFallbackPictures ? users.map(fillInMissingPicture) : users;
}

public async setArchived(vaultId: string, archived: boolean): Promise<VaultDto> {
return axiosAuth.put<VaultDto>(`/vaults/${vaultId}/archived`, String(archived), { headers: { 'Content-Type': 'text/plain' } })
.then(response => {
response.data.creationTime = new Date(response.data.creationTime);
return response.data;
})
.catch((error) => rethrowAndConvertIfExpected(error, 403, 404));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public async createOrUpdateVault(vaultId: string, name: string, archived: boolean, requiredEmergencyKeyShares: number, emergencyKeyShares: Record<string, string>, description?: string): Promise<VaultDto> {
const body: VaultDto = {
id: vaultId,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ArchiveVaultDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function archiveVault() {
onArchiveVaultError.value = undefined;
const v = props.vault;
try {
const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, true, v.requiredEmergencyKeyShares, v.emergencyKeyShares, v.description);
const vaultDto = await backend.vaults.setArchived(v.id, true);
emit('archived', vaultDto);
open.value = false;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ReactivateVaultDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function reactivateVault() {
onReactivateVaultError.value = undefined;
const v = props.vault;
try {
const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, false, v.requiredEmergencyKeyShares, v.emergencyKeyShares, v.description);
const vaultDto = await backend.vaults.setArchived(v.id, false);
emit('reactivated', vaultDto);
open.value = false;
} catch (error) {
Expand Down
Loading
Loading