diff --git a/shogun-boot/src/main/resources/db/migration/V0.14.0__Init_Role.sql b/shogun-boot/src/main/resources/db/migration/V0.14.0__Init_Role.sql new file mode 100644 index 000000000..55bdb87af --- /dev/null +++ b/shogun-boot/src/main/resources/db/migration/V0.14.0__Init_Role.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS shogun.roles ( + id BIGINT PRIMARY KEY, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + name TEXT UNIQUE NOT NULL, + auth_provider_id TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS roleclasspermissions ( + id BIGINT PRIMARY KEY, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + class_name TEXT, + permission_id BIGINT NOT NULL REFERENCES permissions (id), + role_id BIGINT NOT NULL REFERENCES roles (id) +); + +CREATE TABLE IF NOT EXISTS roleinstancepermissions ( + id BIGINT PRIMARY KEY, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + entity_id bigint NOT NULL, + permission_id bigint NOT NULL REFERENCES permissions (id), + role_id BIGINT NOT NULL REFERENCES roles (id) +); + +CREATE TABLE IF NOT EXISTS shogun_rev.roles_rev ( + id BIGINT, + rev INTEGER REFERENCES shogun_rev.revinfo (rev), + revtype SMALLINT, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + name TEXT, + auth_provider_id TEXT, + name_mod BOOLEAN, + auth_provider_id_mod BOOLEAN, + PRIMARY KEY (id, rev) +); + +CREATE TABLE IF NOT EXISTS shogun_rev.roleclasspermissions_rev ( + id BIGINT, + rev INTEGER REFERENCES shogun_rev.revinfo (rev), + revtype SMALLINT, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + class_name TEXT, + permission_id BIGINT, + role_id BIGINT, + class_name_mod BOOLEAN, + permission_id_mod BOOLEAN, + role_id_mod BOOLEAN, + PRIMARY KEY (id, rev) +); + +CREATE TABLE IF NOT EXISTS shogun_rev.roleinstancepermissions_rev ( + id BIGINT, + rev INTEGER REFERENCES shogun_rev.revinfo (rev), + revtype SMALLINT, + created TIMESTAMP WITHOUT TIME ZONE, + modified TIMESTAMP WITHOUT TIME ZONE, + entity_id BIGINT, + permission_id BIGINT, + role_id bigint, + entity_id_mod BOOLEAN, + permission_id_mod BOOLEAN, + role_id_mod BOOLEAN, + PRIMARY KEY (id, rev) +); \ No newline at end of file diff --git a/shogun-config/src/main/resources/application-base.yml b/shogun-config/src/main/resources/application-base.yml index 5363607d6..7e8ab8b17 100644 --- a/shogun-config/src/main/resources/application-base.yml +++ b/shogun-config/src/main/resources/application-base.yml @@ -137,6 +137,8 @@ controller: enabled: true resource: enabled: true + roles: + enabled: true upload: file: diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseFileController.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseFileController.java index 2e18b1211..42480296f 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseFileController.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseFileController.java @@ -39,9 +39,6 @@ @Log4j2 public abstract class BaseFileController, S extends File> extends BasePermissionController { - @Value("${upload.basePath}") - protected String uploadBasePath; - @GetMapping @ResponseStatus(HttpStatus.OK) public Page findAll(@PageableDefault(Integer.MAX_VALUE) @ParameterObject Pageable pageable) { diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/RoleController.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/RoleController.java new file mode 100644 index 000000000..ca8806413 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/RoleController.java @@ -0,0 +1,35 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.controller; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.service.RoleService; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/roles") +@ConditionalOnExpression("${controller.roles.enabled:true}") +@Tag( + name = "Roles", + description = "The endpoints to manage roles" +) +@SecurityRequirement(name = "bearer-key") +public class RoleController extends BaseController { } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/WebhookController.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/WebhookController.java index 167394fb2..d8b75dce6 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/WebhookController.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/WebhookController.java @@ -85,6 +85,21 @@ public void handleKeyCloakEvent(@RequestBody KeycloakEventDto event) { )); } } + case "REALM_ROLE" -> { + if (StringUtils.equals(eventType, "CREATE")) { + applicationEventPublisher.publishEvent(new KeycloakEvent( + this, + KeycloakEventType.REALM_ROLE_CREATED, + split[1] + )); + } else if (StringUtils.equals(eventType, "DELETE")) { + applicationEventPublisher.publishEvent(new KeycloakEvent( + this, + KeycloakEventType.REALM_ROLE_DELETED, + split[1] + )); + } + } case "REALM_ROLE_MAPPING", "CLIENT_ROLE_MAPPING" -> { if (split[0].equals("users")) { applicationEventPublisher.publishEvent(new KeycloakEvent( diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/security/permission/BasePermissionController.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/security/permission/BasePermissionController.java index 432891ba4..d71e3a789 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/security/permission/BasePermissionController.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/security/permission/BasePermissionController.java @@ -22,13 +22,12 @@ import de.terrestris.shogun.lib.exception.security.permission.*; import de.terrestris.shogun.lib.model.BaseEntity; import de.terrestris.shogun.lib.model.Group; +import de.terrestris.shogun.lib.model.Role; import de.terrestris.shogun.lib.model.User; -import de.terrestris.shogun.lib.model.security.permission.GroupClassPermission; -import de.terrestris.shogun.lib.model.security.permission.GroupInstancePermission; -import de.terrestris.shogun.lib.model.security.permission.UserClassPermission; -import de.terrestris.shogun.lib.model.security.permission.UserInstancePermission; +import de.terrestris.shogun.lib.model.security.permission.*; import de.terrestris.shogun.lib.service.BaseService; import de.terrestris.shogun.lib.service.GroupService; +import de.terrestris.shogun.lib.service.RoleService; import de.terrestris.shogun.lib.service.UserService; import de.terrestris.shogun.lib.service.security.permission.*; import lombok.extern.log4j.Log4j2; @@ -59,6 +58,9 @@ public abstract class BasePermissionController, S ex @Autowired protected GroupService groupService; + @Autowired + protected RoleService roleService; + @Autowired protected PublicInstancePermissionService publicInstancePermissionService; @@ -74,6 +76,12 @@ public abstract class BasePermissionController, S ex @Autowired protected GroupClassPermissionServiceSecured groupClassPermissionService; + @Autowired + protected RoleInstancePermissionService roleInstancePermissionService; + + @Autowired + protected RoleClassPermissionService roleClassPermissionService; + @GetMapping("/{id}/permissions/instance/user") @ResponseStatus(HttpStatus.OK) public List getUserInstancePermissions(@PathVariable("id") Long entityId) { @@ -134,6 +142,36 @@ public List getGroupInstancePermissions(@PathVariable(" } } + @GetMapping("/{id}/permissions/instance/role") + @ResponseStatus(HttpStatus.OK) + public List getRoleInstancePermissions(@PathVariable("id") Long entityId) { + log.trace("Requested to get all role instance permissions for entity of " + + "type {} with ID {}", getGenericClassName(), entityId); + + try { + Optional entity = service.findOne(entityId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + List permissions = roleInstancePermissionService + .findFor(entity.get()); + + log.trace("Successfully got all role instance permissions for entity " + + "of type {} with ID {} (count: {})", getGenericClassName(), entityId, + permissions.size()); + + return permissions; + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new ReadPermissionException(e, messageSource); + } + } + @GetMapping("/{id}/permissions/class/user") @ResponseStatus(HttpStatus.OK) public List getUserClassPermissions(@PathVariable("id") Long entityId) { @@ -194,6 +232,36 @@ public List getGroupClassPermissions(@PathVariable("id") L } } + @GetMapping("/{id}/permissions/class/role") + @ResponseStatus(HttpStatus.OK) + public List getRoleClassPermissions(@PathVariable("id") Long entityId) { + log.trace("Requested to get all role class permissions for entity of " + + "type {} with ID {}", getGenericClassName(), entityId); + + try { + Optional entity = service.findOne(entityId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + List permissions = roleClassPermissionService + .findFor(entity.get()); + + log.trace("Successfully got all role class permissions for entity " + + "of type {} with ID {} (count: {})", getGenericClassName(), entityId, + permissions.size()); + + return permissions; + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new ReadPermissionException(e, messageSource); + } + } + @GetMapping("/{id}/permissions/instance/user/{userId}") @ResponseStatus(HttpStatus.OK) public UserInstancePermission getUserInstancePermission( @@ -276,6 +344,47 @@ public GroupInstancePermission getGroupInstancePermission( } } + @GetMapping("/{id}/permissions/instance/role/{roleId}") + @ResponseStatus(HttpStatus.OK) + public RoleInstancePermission getRoleInstancePermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId + ) { + log.trace("Requested to get the role instance permission for entity of " + + "type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + Optional permission = roleInstancePermissionService + .findFor(entity.get(), role.get()); + + if (permission.isEmpty()) { + throw new EntityPermissionNotFoundException(entityId, getGenericClassName(), messageSource); + } + + log.trace("Successfully got the role instance permission for entity " + + "of type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + return permission.get(); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new ReadPermissionException(e, messageSource); + } + } + @GetMapping("/{id}/permissions/class/user/{userId}") @ResponseStatus(HttpStatus.OK) public UserClassPermission getUserClassPermission( @@ -358,6 +467,47 @@ public GroupClassPermission getGroupClassPermission( } } + @GetMapping("/{id}/permissions/class/role/{roleId}") + @ResponseStatus(HttpStatus.OK) + public RoleClassPermission getRoleClassPermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId) + { + log.trace("Requested to get the role class permission for entity of " + + "type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + Optional permission = roleClassPermissionService + .findFor(entity.get().getClass(), role.get()); + + if (permission.isEmpty()) { + throw new EntityPermissionNotFoundException(entityId, getGenericClassName(), messageSource); + } + + log.trace("Successfully got the role class permission for entity of " + + "type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + return permission.get(); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new ReadPermissionException(e, messageSource); + } + } + @PostMapping("/{id}/permissions/instance/user/{userId}") @ResponseStatus(HttpStatus.CREATED) public void setUserInstancePermission( @@ -430,6 +580,42 @@ public void setGroupInstancePermission( } } + @PostMapping("/{id}/permissions/instance/role/{roleId}") + @ResponseStatus(HttpStatus.CREATED) + public void setRoleInstancePermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId, + @RequestBody PermissionCollectionTypeDto permissionType + ) { + log.trace("Requested to set the role instance permission for entity of " + + "type {} with ID {} for role with ID {} to {}", getGenericClassName(), entityId, + roleId, permissionType.getPermission()); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + roleInstancePermissionService.setPermission(entity.get(), role.get(), permissionType.getPermission()); + + log.trace("Successfully set the role instance permission for entity " + + "of type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new CreatePermissionException(e, messageSource); + } + } + @PostMapping("/{id}/permissions/class/user/{userId}") @ResponseStatus(HttpStatus.CREATED) public void setUserClassPermission( @@ -475,7 +661,7 @@ public void setGroupClassPermission( @RequestBody PermissionCollectionTypeDto permissionType ) { log.trace("Requested to set the group class permission for entity of " + - "type {} with ID {} for user with ID {} to {}", getGenericClassName(), entityId, + "type {} with ID {} for group with ID {} to {}", getGenericClassName(), entityId, groupId, permissionType.getPermission()); try { @@ -504,6 +690,43 @@ public void setGroupClassPermission( } } + @PostMapping("/{id}/permissions/class/role/{roleId}") + @ResponseStatus(HttpStatus.CREATED) + public void setRoleClassPermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId, + @RequestBody PermissionCollectionTypeDto permissionType + ) { + log.trace("Requested to set the role class permission for entity of " + + "type {} with ID {} for role with ID {} to {}", getGenericClassName(), entityId, + roleId, permissionType.getPermission()); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + roleClassPermissionService.setPermission(entity.get().getClass(), + role.get(), permissionType.getPermission()); + + log.trace("Successfully set the role class permission for entity " + + "of type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new CreatePermissionException(e, messageSource); + } + } + @DeleteMapping("/{id}/permissions/instance/user/{userId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUserInstancePermission( @@ -574,6 +797,41 @@ public void deleteGroupInstancePermission( } } + @DeleteMapping("/{id}/permissions/instance/role/{roleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRoleInstancePermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId + ) { + log.trace("Requested to delete the role instance permission for entity " + + "of type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + roleInstancePermissionService.deleteFor(entity.get(), role.get()); + + log.trace("Successfully deleted the role instance permission for " + + "entity of type {} with ID {} for role with ID {}", getGenericClassName(), + entityId, roleId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new DeletePermissionException(e, messageSource); + } + } + @DeleteMapping("/{id}/permissions/class/user/{userId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUserClassPermission( @@ -642,6 +900,40 @@ public void deleteGroupClassPermission( } } + @DeleteMapping("/{id}/permissions/class/role/{roleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRoleClassPermission( + @PathVariable("id") Long entityId, + @PathVariable("roleId") Long roleId + ) { + log.trace("Requested to delete the role class permission for entity of " + + "type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + + try { + Optional entity = service.findOne(entityId); + Optional role = roleService.findOne(roleId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + if (role.isEmpty()) { + throw new RoleNotFoundException(roleId, messageSource); + } + + roleClassPermissionService.deleteFor(entity.get(), role.get()); + + log.trace("Successfully deleted the role class permission for entity " + + "of type {} with ID {} for role with ID {}", getGenericClassName(), entityId, roleId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new DeletePermissionException(e, messageSource); + } + } + @DeleteMapping("/{id}/permissions/instance/user") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUserInstancePermissions( @@ -698,6 +990,34 @@ public void deleteGroupInstancePermissions( } } + @DeleteMapping("/{id}/permissions/instance/role") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRoleInstancePermissions( + @PathVariable("id") Long entityId + ) { + log.trace("Requested to delete all role instance permissions for entity " + + "of type {} with ID {}", getGenericClassName(), entityId); + + try { + Optional entity = service.findOne(entityId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + roleInstancePermissionService.deleteAllFor(entity.get()); + + log.trace("Successfully deleted all role instance permissions for entity " + + "of type {} with ID {}", getGenericClassName(), entityId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new DeletePermissionException(e, messageSource); + } + } + @DeleteMapping("/{id}/permissions/class/user") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteUserClassPermissions( @@ -754,6 +1074,34 @@ public void deleteGroupClassPermissions( } } + @DeleteMapping("/{id}/permissions/class/role") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteRoleClassPermissions( + @PathVariable("id") Long entityId + ) { + log.trace("Requested to delete all role class permissions for entity of " + + "type {} with ID {}", getGenericClassName(), entityId); + + try { + Optional entity = service.findOne(entityId); + + if (entity.isEmpty()) { + throw new EntityNotFoundException(entityId, getGenericClassName(), messageSource); + } + + roleClassPermissionService.deleteAllFor(entity.get()); + + log.trace("Successfully deleted all role instance permissions for entity " + + "of type {} with ID {}", getGenericClassName(), entityId); + } catch (AccessDeniedException ade) { + throw new EntityAccessDeniedException(entityId, getGenericClassName(), messageSource); + } catch (ResponseStatusException rse) { + throw rse; + } catch (Exception e) { + throw new DeletePermissionException(e, messageSource); + } + } + @GetMapping("/{id}/permissions/public") @ResponseStatus(HttpStatus.OK) public Map isPublic( diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/event/KeycloakEventType.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/event/KeycloakEventType.java index 7a02bd873..53817f618 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/event/KeycloakEventType.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/event/KeycloakEventType.java @@ -24,5 +24,7 @@ public enum KeycloakEventType { USER_GROUP_MEMBERSHIP_CHANGED, GROUP_CREATED, GROUP_DELETED, - GROUP_ROLES_CHANGED + GROUP_ROLES_CHANGED, + REALM_ROLE_CREATED, + REALM_ROLE_DELETED } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/exception/security/permission/RoleNotFoundException.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/exception/security/permission/RoleNotFoundException.java new file mode 100644 index 000000000..98b179d27 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/exception/security/permission/RoleNotFoundException.java @@ -0,0 +1,47 @@ +/* + * SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.terrestris.shogun.lib.exception.security.permission; + +import lombok.extern.log4j.Log4j2; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Log4j2 +public final class RoleNotFoundException extends ResponseStatusException { + + public RoleNotFoundException(Long roleId, String message) { + super(HttpStatus.NOT_FOUND, message); + + log.error("Could not find role with ID {}", roleId); + } + + public RoleNotFoundException(Long roleId, MessageSource messageSource) { + this( + roleId, + messageSource.getMessage( + "BaseController.NOT_FOUND", + null, + LocaleContextHolder.getLocale() + ) + ); + } +} + diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/listener/KeycloakEventListener.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/listener/KeycloakEventListener.java index 3fcb195d4..9ffd42604 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/listener/KeycloakEventListener.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/listener/KeycloakEventListener.java @@ -22,6 +22,7 @@ import de.terrestris.shogun.lib.event.OnRegistrationConfirmedEvent; import de.terrestris.shogun.lib.service.security.permission.UserInstancePermissionService; import de.terrestris.shogun.lib.service.security.provider.GroupProviderService; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; import de.terrestris.shogun.lib.service.security.provider.UserProviderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.event.EventListener; @@ -36,6 +37,9 @@ public class KeycloakEventListener { @Autowired private GroupProviderService groupProviderService; + @Autowired + private RoleProviderService roleProviderService; + @Autowired protected UserInstancePermissionService userInstancePermissionService; @@ -44,6 +48,7 @@ public void onKeycloakEvent(KeycloakEvent event) { switch (event.getEventType()) { case USER_CREATED -> userProviderService.findOrCreateByProviderId(event.getKeycloakId()); case GROUP_CREATED -> groupProviderService.findOrCreateByProviderId(event.getKeycloakId()); + case REALM_ROLE_CREATED -> roleProviderService.findOrCreateByProviderId(event.getKeycloakId()); } } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/Role.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/Role.java new file mode 100644 index 000000000..23aa549ca --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/Role.java @@ -0,0 +1,80 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.Hibernate; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.envers.AuditTable; +import org.hibernate.envers.Audited; + +import java.util.Objects; + +@Entity(name = "roles") +@Table(schema = "shogun") +@DynamicUpdate +@Audited +@AuditTable(value = "roles_rev", schema = "shogun_rev") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "roles") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString(callSuper = true) +public class Role extends BaseEntity { + + @Column(unique = true, nullable = false) + @Schema( + description = "The name of the role.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + // TODO Required? + private String name; + + @Column(unique = true, nullable = false) + @Schema( + description = "The backend ID of the user.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private String authProviderId; + + @Transient + @Schema( + description = "The role details stored in the associated provider.", + accessMode = Schema.AccessMode.READ_ONLY + ) + private T providerDetails; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + Role user = (Role) o; + return getId() != null && Objects.equals(getId(), user.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} + diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleClassPermission.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleClassPermission.java new file mode 100644 index 000000000..f1c70e861 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleClassPermission.java @@ -0,0 +1,64 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.model.security.permission; + +import de.terrestris.shogun.lib.model.Role; +import jakarta.persistence.Cacheable; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.*; +import org.hibernate.Hibernate; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.envers.AuditTable; +import org.hibernate.envers.Audited; + +import java.util.Objects; + +@Entity(name = "roleclasspermissions") +@Table(schema = "shogun") +@DynamicUpdate +@Audited +@AuditTable(value = "roleclasspermissions_rev", schema = "shogun_rev") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "roleclasspermissions") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString(callSuper = true) +public class RoleClassPermission extends ClassPermission { + + @ManyToOne(optional = false) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) + private Role role; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + RoleClassPermission that = (RoleClassPermission) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleInstancePermission.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleInstancePermission.java new file mode 100644 index 000000000..abf408cf1 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/model/security/permission/RoleInstancePermission.java @@ -0,0 +1,64 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.model.security.permission; + +import de.terrestris.shogun.lib.model.Role; +import jakarta.persistence.Cacheable; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.*; +import org.hibernate.Hibernate; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.envers.AuditTable; +import org.hibernate.envers.Audited; + +import java.util.Objects; + +@Entity(name = "roleinstancepermissions") +@Table(schema = "shogun") +@DynamicUpdate +@Audited +@AuditTable(value = "roleinstancepermissions_rev", schema = "shogun_rev") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "roleinstancepermissions") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString(callSuper = true) +public class RoleInstancePermission extends InstancePermission { + + @ManyToOne(optional = false) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) + private Role role; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; + RoleInstancePermission that = (RoleInstancePermission) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/ApplicationRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/ApplicationRepository.java index e3f7a114c..e2ca93675 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/ApplicationRepository.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/ApplicationRepository.java @@ -31,4 +31,24 @@ public interface ApplicationRepository extends BaseCrudRepository findByName(String name); + // https://github.com/spring-projects/spring-data-jpa/issues/2644 +// @Query( +// value = "SELECT * FROM {h-schema}applications a WHERE jsonb_path_exists(a.layer_tree, '$.** \\\\?\\\\? (@.\"layerId\" == $id)', jsonb_build_object('id', :layerId))", +// nativeQuery = true +// ) +// @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) +// List findAllByLayerId(@Param("layerId") Long layerId); + +// @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) +// @Query( +// value = "SELECT * FROM shogun.applications a WHERE jsonb_path_exists(a.layer_tree, '$.** ? (@.\"layerId\" == $id)', jsonb_build_object('id', :layerId))", +// nativeQuery = true +// ) +// List findAllApplicationsContainingLayer(Long layerId); +// +// @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) +// @Query( +// value = "SELECT a FROM Application a WHERE a.public_access = true" +// ) +// Page findAllOpenApplications(Pageable pageable); } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java index 926109e6b..08e7cb1ab 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java @@ -56,10 +56,15 @@ public interface BaseCrudRepository extends WHERE uip.user.id = :userId AND uip.entityId = m.id AND uip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') + ) OR EXISTS ( + SELECT 1 FROM roleinstancepermissions rip + WHERE rip.role.id IN :roleIds + AND rip.entityId = m.id + AND rip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') ) """) @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) - Page findAll(Pageable pageable, Long userId); + Page findAll(Pageable pageable, Long userId, List roleIds); /** * Returns a {@link Page} of entities for which the user with userId has permission via UserInstancePermission or GroupInstancePermission. @@ -85,10 +90,15 @@ AND uip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE' WHERE gip.group.id IN :groupIds AND gip.entityId = m.id AND gip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') + ) OR EXISTS ( + SELECT 1 FROM roleinstancepermissions rip + WHERE rip.role.id IN :roleIds + AND rip.entityId = m.id + AND rip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') ) """) @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) - Page findAll(Pageable pageable, Long userId, List groupIds); + Page findAll(Pageable pageable, Long userId, List groupIds, List roleIds); /** * Returns a {@link Page} of entities without checking any permissions. diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/RoleRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/RoleRepository.java new file mode 100644 index 000000000..25a2b2a75 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/RoleRepository.java @@ -0,0 +1,36 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.repository; + +import de.terrestris.shogun.lib.model.Role; +import jakarta.persistence.QueryHint; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RoleRepository extends BaseCrudRepository, JpaSpecificationExecutor { + + @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) + Optional findByName(String name); + + @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) + Optional findByAuthProviderId(String authProviderId); + +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleClassPermissionRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleClassPermissionRepository.java new file mode 100644 index 000000000..912dd062e --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleClassPermissionRepository.java @@ -0,0 +1,49 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.repository.security.permission; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.security.permission.RoleClassPermission; +import jakarta.persistence.QueryHint; +import org.hibernate.jpa.AvailableHints; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface RoleClassPermissionRepository extends BasePermissionRepository, + JpaSpecificationExecutor { + + @Query("Select rcp from roleclasspermissions rcp where rcp.role.id = ?1 and rcp.className = ?2") + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + Optional findByRoleIdAndClassName(Long roleId, String className); + + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + List findAllByRole(Role role); + + @Modifying + @Query(value = "DELETE FROM {h-schema}roleclasspermissions u WHERE u.role_id=:roleId", nativeQuery = true) + void deleteAllByRoleId(@Param("roleId") Long roleId); + + List findByClassName(String className); +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleInstancePermissionRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleInstancePermissionRepository.java new file mode 100644 index 000000000..c27a7fca4 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/security/permission/RoleInstancePermissionRepository.java @@ -0,0 +1,53 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2020-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.repository.security.permission; + +import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.security.permission.RoleInstancePermission; +import jakarta.persistence.QueryHint; +import org.hibernate.jpa.AvailableHints; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface RoleInstancePermissionRepository extends BasePermissionRepository, + JpaSpecificationExecutor { + + @Query("Select rip from roleinstancepermissions rip where rip.role.id = ?1 and rip.entityId = ?2") + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + Optional findByRoleIdAndEntityId(Long roleId, Long entityId); + + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + List findByEntityId(Long entityId); + + @Query("SELECT rip FROM roleinstancepermissions rip LEFT JOIN rip.permission p WHERE rip.entityId = :entityId AND p.name = :permissionCollectionType") + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + List findByEntityAndPermissionCollectionType( + @Param("entityId") Long entityId, + @Param("permissionCollectionType") PermissionCollectionType permissionCollectionType + ); + + @QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true")) + List findAllByRole(Role role); +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java index e9cc2d71e..fda24784e 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java @@ -19,14 +19,14 @@ import de.terrestris.shogun.lib.enumeration.PermissionType; import de.terrestris.shogun.lib.model.BaseEntity; import de.terrestris.shogun.lib.model.Group; +import de.terrestris.shogun.lib.model.Role; import de.terrestris.shogun.lib.model.User; -import de.terrestris.shogun.lib.model.security.permission.ClassPermission; -import de.terrestris.shogun.lib.model.security.permission.GroupClassPermission; -import de.terrestris.shogun.lib.model.security.permission.PermissionCollection; -import de.terrestris.shogun.lib.model.security.permission.UserClassPermission; +import de.terrestris.shogun.lib.model.security.permission.*; import de.terrestris.shogun.lib.repository.BaseCrudRepository; +import de.terrestris.shogun.lib.repository.RoleRepository; import de.terrestris.shogun.lib.service.security.permission.*; import de.terrestris.shogun.lib.service.security.provider.GroupProviderService; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; import org.keycloak.representations.idm.GroupRepresentation; @@ -41,6 +41,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import java.util.*; +import java.util.stream.Collectors; @Log4j2 public abstract class BaseEntityPermissionEvaluator implements EntityPermissionEvaluator { @@ -60,12 +61,24 @@ public abstract class BaseEntityPermissionEvaluator implem @Autowired protected GroupProviderService groupProviderService; + @Autowired + protected RoleProviderService roleProviderService; + + @Autowired + protected RoleInstancePermissionService roleInstancePermissionService; + + @Autowired + protected RoleClassPermissionService roleClassPermissionService; + @Autowired private PublicInstancePermissionService publicInstancePermissionService; @Autowired protected List baseCrudRepositories; + @Autowired + protected RoleRepository roleRepository; + @Override public Class getEntityClassName() { return (Class) GenericTypeResolver.resolveTypeArgument(getClass(), BaseEntityPermissionEvaluator.class); @@ -102,6 +115,13 @@ public boolean hasPermission(User user, E entity, PermissionType permission) { return true; } + // CHECK ROLE INSTANCE PERMISSIONS + if (this.hasPermissionByRoleInstancePermission(user, entity, permission)) { + log.trace("Granting {} access by role instance permissions", permission); + + return true; + } + // CHECK USER CLASS PERMISSIONS if (this.hasPermissionByUserClassPermission(user, entity, permission)) { log.trace("Granting {} access by user class permissions", permission); @@ -116,6 +136,13 @@ public boolean hasPermission(User user, E entity, PermissionType permission) { return true; } + // CHECK ROLE CLASS PERMISSIONS + if (this.hasPermissionByRoleClassPermission(user, entity, permission)) { + log.trace("Granting {} access by role instance permissions", permission); + + return true; + } + log.trace("Restricting {} access on secured object '{}' with ID {}", permission, simpleClassName, entity.getId()); @@ -230,6 +257,28 @@ public boolean hasPermissionByGroupInstancePermission(User user, BaseEntity enti groupInstancePermissions.contains(PermissionType.ADMIN); } + public boolean hasPermissionByRoleInstancePermission(User user, BaseEntity entity, PermissionType permission) { + List roles = roleProviderService.getRolesForUser(user); + + List rolePermissionCols; + if (permission.equals(PermissionType.CREATE) && entity.getId() == null) { + rolePermissionCols = List.of(new PermissionCollection()); + } else { + rolePermissionCols = roles.stream() + .map(role -> roleInstancePermissionService.findPermissionCollectionFor(entity, role)) + .toList(); + } + + return rolePermissionCols.stream().anyMatch(rolePermissionCol -> { + final Set roleInstancePermissions = rolePermissionCol.getPermissions(); + + // Grant access if user explicitly has the requested permission or + // if the user has the ADMIN permission + return roleInstancePermissions.contains(permission) || + roleInstancePermissions.contains(PermissionType.ADMIN); + }); + } + public boolean hasPermissionByUserClassPermission(User user, BaseEntity entity, PermissionType permission) { PermissionCollection userClassPermissionCol = userClassPermissionService .findPermissionCollectionFor(entity, user); @@ -252,6 +301,23 @@ public boolean hasPermissionByGroupClassPermission(User user, BaseEntity entity, groupClassPermissions.contains(PermissionType.ADMIN); } + public boolean hasPermissionByRoleClassPermission(User user, BaseEntity entity, PermissionType permission) { + List roles = roleProviderService.getRolesForUser(user); + + List rolePermissionCols = roles.stream() + .map(role -> roleClassPermissionService.findPermissionCollectionFor(entity, role)) + .toList(); + + return rolePermissionCols.stream().anyMatch(rolePermissionCol -> { + final Set roleClassPermissions = rolePermissionCol.getPermissions(); + + // Grant access if user explicitly has the requested permission or + // if the user has the ADMIN permission + return roleClassPermissions.contains(permission) || + roleClassPermissions.contains(PermissionType.ADMIN); + }); + } + /** * Default findAll implementation which supports pagination. * Speeds up the permission check by utilizing two simplifications: @@ -267,10 +333,10 @@ public boolean hasPermissionByGroupClassPermission(User user, BaseEntity entity, @Override public Page findAll(User user, Pageable pageable, BaseCrudRepository repository, Class baseEntityClass) { if (user == null) { - return repository.findAll(pageable, null); + return repository.findAll(pageable, null, null); } - // option A: user has role `ADMIN` + // option A: user has role `ADMIN`. Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); List authorities = new ArrayList<>(authentication.getAuthorities()); @@ -282,7 +348,7 @@ public Page findAll(User user, Pageable pageable, BaseCrudRepository return repository.findAll(pageable); } - // option B: user has permission through class permissions + // option B: user has permission through instance or group class permissions. Optional userClassPermission = userClassPermissionService.findFor(baseEntityClass, user); Optional groupClassPermission = groupClassPermissionService.findFor(baseEntityClass, user); @@ -290,17 +356,38 @@ public Page findAll(User user, Pageable pageable, BaseCrudRepository return repository.findAll(pageable); } - // option C: check instance permissions for each entity with a single query + // option C: user has permission through role class permissions. + List roles = roleProviderService.getRolesForUser(user); + + List roleClassPermissions = roles.stream() + .map(role -> roleClassPermissionService.findFor(baseEntityClass, role)) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + if (containsReadPermission(roleClassPermissions.toArray(RoleClassPermission[]::new))) { + return repository.findAll(pageable); + } + + // TODO take into account the role permissions + // TODO das sollte der letzte notwendige Schritt sein. + // option D: user has permission through role instance permissions. + + List roleIds = roles.stream() + .map(BaseEntity::getId) + .toList(); + + // option E: check instance permissions for each entity with a single query. List> userGroups = groupProviderService.getGroupsForUser(); if (userGroups.isEmpty()) { // user has no groups so only user instance permissions have to be checked - return repository.findAll(pageable, user.getId()); + return repository.findAll(pageable, user.getId(), roleIds); } else { // check both user and group instance permissions List groupIds = userGroups.stream() .map(BaseEntity::getId) .toList(); - return repository.findAll(pageable, user.getId(), groupIds); + return repository.findAll(pageable, user.getId(), groupIds, roleIds); } } @@ -316,7 +403,6 @@ private boolean containsReadPermission(ClassPermission ...classPermissions) { } protected boolean hasPublicPermission(E entity) { - if (entity instanceof Group || entity instanceof User) { return false; } @@ -324,6 +410,25 @@ protected boolean hasPublicPermission(E entity) { return publicInstancePermissionService.getPublic(entity); } +// /** +// * Returns the roles of the currently authenticated user. +// * @return +// */ +// protected List getUserRoles() { +// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); +// +// Collection grantedAuthorities = authentication.getAuthorities(); +// +// return grantedAuthorities.stream() +// .map(grantedAuthority -> { +// String authority = grantedAuthority.getAuthority(); +// return roleRepository.findByName(authority); +// }) +// .filter(Optional::isPresent) +// .map(Optional::get) +// .toList(); +// } + /** * Returns the class of the {@link BaseEntity} this abstract class * has been declared with, e.g. 'Application.class'. diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/RolePermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/RolePermissionEvaluator.java new file mode 100644 index 000000000..0151ccae9 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/RolePermissionEvaluator.java @@ -0,0 +1,43 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2023-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.security.access.entity; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.User; +import de.terrestris.shogun.lib.repository.BaseCrudRepository; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +public class RolePermissionEvaluator extends BaseEntityPermissionEvaluator { + + @Autowired + RoleProviderService roleProviderService; + + @Override + public Page findAll(User user, Pageable pageable, BaseCrudRepository repository, + Class baseEntityClass) { + Page roles = super.findAll(user, pageable, repository, baseEntityClass); + + roles.forEach(u -> roleProviderService.setTransientRepresentations(u)); + + return roles; + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/RoleService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/RoleService.java new file mode 100644 index 000000000..6775eae0e --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/RoleService.java @@ -0,0 +1,113 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.service; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.repository.RoleRepository; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PostFilter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Log4j2 +public class RoleService extends BaseService { + + @Autowired + RoleProviderService roleProviderService; + + @PostFilter("hasRole('ROLE_ADMIN') or hasPermission(filterObject, 'READ')") + @Transactional(readOnly = true) + @Override + public List findAll() { + List roles = repository.findAll(); + + for (Role role : roles) { + roleProviderService.setTransientRepresentations(role); + } + + return roles; + } + + @PostFilter("hasRole('ROLE_ADMIN') or hasPermission(filterObject, 'READ')") + @Transactional(readOnly = true) + @Override + public List findAllBy(Specification specification) { + List roles = (List) repository.findAll(specification); + + for (Role role : roles) { + roleProviderService.setTransientRepresentations(role); + } + + return roles; + } + + @PostAuthorize("hasRole('ROLE_ADMIN') or hasPermission(returnObject.orElse(null), 'READ')") + @Transactional(readOnly = true) + @Override + public Optional findOne(Long id) { + Optional role = repository.findById(id); + + if (role.isPresent()) { + roleProviderService.setTransientRepresentations(role.get()); + } + + return role; + } + + @PostAuthorize("hasRole('ROLE_ADMIN') or hasPermission(returnObject.orElse(null), 'READ')") + @Transactional(readOnly = true) + public Optional findByKeyCloakId(String keycloakId) { + Optional role = repository.findByAuthProviderId(keycloakId); + + if (role.isPresent()) { + roleProviderService.setTransientRepresentations(role.get()); + } + + return role; + } + + /** + * Delete a role from the SHOGun DB by its provider Id. + * + * @param authProviderId + */ + @Transactional +// @PreAuthorize("hasRole('ROLE_ADMIN') or hasPermission(#keycloakUserId, 'DELETE')") + public void deleteByAuthProviderId(String authProviderId) { + Optional roleOptional = repository.findByAuthProviderId(authProviderId); + Role role = roleOptional.orElse(null); + + if (role == null) { + log.debug("Role with keycloak id {} was deleted in Keycloak. It did not exists in SHOGun DB. No action needed.", authProviderId); + return; + } + + // TODO +// roleInstancePermissionService.deleteAllFor(role); + repository.delete(role); + log.info("Role with keycloak id {} was deleted in Keycloak and was therefore deleted in SHOGun DB, too.", authProviderId); + } + +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleClassPermissionService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleClassPermissionService.java new file mode 100644 index 000000000..3114fc12c --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleClassPermissionService.java @@ -0,0 +1,273 @@ +/* + * SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.terrestris.shogun.lib.service.security.permission; + +import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; +import de.terrestris.shogun.lib.model.BaseEntity; +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.security.permission.PermissionCollection; +import de.terrestris.shogun.lib.model.security.permission.RoleClassPermission; +import de.terrestris.shogun.lib.repository.security.permission.PermissionCollectionRepository; +import de.terrestris.shogun.lib.repository.security.permission.RoleClassPermissionRepository; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Log4j2 +@Service +public class RoleClassPermissionService extends BasePermissionService { + + @Autowired + protected PermissionCollectionRepository permissionCollectionRepository; + + @Autowired + private RoleProviderService roleProviderService; + + /** + * Returns all {@link RoleClassPermission} for the given query arguments. + * + * @param role The role to find the permissions for. + * @return The permissions. + */ + public List findFor(Role role) { + log.trace("Getting all role class permissions for role with Keycloak ID {}", + role.getAuthProviderId()); + + List permissions = repository.findAllByRole(role); + + setAuthProviderRepresentation(permissions); + + return permissions; + } + + /** + * Get all {@link RoleClassPermission} for the given entity. + * + * @param entity entity to get role permissions for + * @return + */ + public List findFor(BaseEntity entity) { + String className = entity.getClass().getCanonicalName(); + + log.trace("Getting all role class permissions for entity class {}", className); + + List permissions = repository.findByClassName(className); + + setAuthProviderRepresentation(permissions); + + return permissions; + } + + /** + * Return {@link Optional} containing {@link RoleClassPermission} + * + * @param clazz The class that should be checked + * @param role The role to check for + * @return {@link Optional} containing {@link RoleClassPermission} + */ + public Optional findFor(Class clazz, Role role) { + String className = clazz.getCanonicalName(); + + log.trace("Getting all role class permissions for role with Keycloak ID {} and " + + "entity class {}", role.getAuthProviderId(), className); + + Optional permission = repository.findByRoleIdAndClassName(role.getId(), className); + + if (permission.isPresent()) { + setAuthProviderRepresentation(permission.get()); + } + + return permission; + } + + /** + * Returns the {@link RoleClassPermission} for the given query arguments. Hereby + * the class of the given entity will be considered. + * + * @param entity The entity to find the permission for. + * @param role The role to find the permission for. + * @return The (optional) permission. + */ + public Optional findFor(BaseEntity entity, Role role) { + log.trace("Getting all role class permissions for role with Keycloak ID {} and " + + "entity class {}", role.getAuthProviderId(), entity.getClass().getCanonicalName()); + + Optional permission = repository.findByRoleIdAndClassName(role.getId(), entity.getClass().getCanonicalName()); + + if (permission.isPresent()) { + setAuthProviderRepresentation(permission.get()); + } + + return permission; + } + + /** + * Returns the {@link PermissionCollection} for the given query arguments. + * + * @param entity The entity to find the collection for. + * @param role The role to find the collection for. + * @return The collection (may be empty). + */ + public PermissionCollection findPermissionCollectionFor(BaseEntity entity, Role role) { + Class clazz = entity.getClass(); + Optional roleClassPermission = this.findFor(clazz, role); + + return getPermissionCollection(roleClassPermission); + } + + // TODO makes no sense for roles class? +// /** +// * Sets the given {@link PermissionCollectionType} for the given class and the currently +// * logged in user. +// * +// * @param clazz The class to set the permission for. +// * @param permissionCollectionType The permission to set. +// */ +// public void setPermission(Class clazz, PermissionCollectionType permissionCollectionType) { +// Optional activeUser = userProviderService.getUserBySession(); +// +// if (activeUser.isEmpty()) { +// throw new RuntimeException("Could not detect the logged in user."); +// } +// +// setPermission(clazz, activeUser.get(), permissionCollectionType); +// } + + /** + * Sets the given {@link PermissionCollectionType} for the given class and role. + * + * @param clazz The class to find set the permission for. + * @param role The role to find set the permission for. + * @param permissionCollectionType The permission to set. + */ + public void setPermission(Class clazz, Role role, PermissionCollectionType permissionCollectionType) { + Optional permissionCollection = permissionCollectionRepository + .findByName(permissionCollectionType); + + if (permissionCollection.isEmpty()) { + throw new RuntimeException("Could not find requested permission collection"); + } + + clearExistingPermission(role, permissionCollection.get(), clazz); + + RoleClassPermission roleClassPermission = new RoleClassPermission(); + roleClassPermission.setRole(role); + roleClassPermission.setClassName(clazz.getCanonicalName()); + roleClassPermission.setPermission(permissionCollection.get()); + + repository.save(roleClassPermission); + } + + /** + * Clears the given {@link PermissionCollection} for the given target combination. + * + * @param role The role to clear the permission for. + * @param permissionCollection The permission collection to clear. + * @param clazz The class to clear the permission for. + */ + private void clearExistingPermission(Role role, PermissionCollection permissionCollection, Class clazz) { + Optional existingPermission = findFor(clazz, role); + + // Check if there is already an existing permission set on the entity. + if (existingPermission.isPresent()) { + log.debug("Permission is already set for clazz {} and role with " + + "Keycloak ID {}: {}", clazz.getCanonicalName(), role.getAuthProviderId(), permissionCollection); + + // Remove the existing one + repository.delete(existingPermission.get()); + + log.debug("Removed the permission"); + } + } + + /** + * Deletes all {@link RoleClassPermission} for the given entity. + * + * @param persistedEntity The entity to clear the permissions for. + */ + public void deleteAllFor(BaseEntity persistedEntity) { + List roleClassPermissions = this.findFor(persistedEntity); + + repository.deleteAll(roleClassPermissions); + + log.info("Successfully deleted all role class permissions for entity with ID {}", + persistedEntity.getId()); + } + + /** + * Deletes all {@link RoleClassPermission} for the given role. + * + * @param role The role to clear the permissions for. + */ + public void deleteAllFor(Role role) { + List roleClassPermissions = this.findFor(role); + + repository.deleteAll(roleClassPermissions); + + log.info("Successfully deleted all role class permissions for role with ID {}", + role.getId()); + } + + /** + * Deletes the {@link RoleClassPermission} for the given entity and role. + * + * @param persistedEntity The entity to clear the permissions for. + * @param role The role to clear the permission for. + */ + public void deleteFor(BaseEntity persistedEntity, Role role) { + Optional roleClassPermission = this.findFor(persistedEntity.getClass(), role); + + if (roleClassPermission.isPresent()) { + repository.delete(roleClassPermission.get()); + + log.info("Successfully deleted the role class permission for entity with ID {} and role {}.", + persistedEntity.getId(), role.getId()); + } else { + log.warn("Could not delete the role class permission. The requested permission does not exist."); + } + } + + /** + * Helper function to get the {@link PermissionCollection} from a given + * class permission. If no collection is available, it returns an empty + * list. + * + * @param classPermission The classPermission to get the permissions from. + * @return The collection (may be empty). + */ + private PermissionCollection getPermissionCollection(Optional classPermission) { + if (classPermission.isPresent()) { + return classPermission.get().getPermission(); + } + + return new PermissionCollection(); + } + + private void setAuthProviderRepresentation(RoleClassPermission permission) { + roleProviderService.setTransientRepresentations(permission.getRole()); + } + + private void setAuthProviderRepresentation(List permissions) { + permissions.forEach((roleClassPermission -> setAuthProviderRepresentation(roleClassPermission))); + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleInstancePermissionService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleInstancePermissionService.java new file mode 100644 index 000000000..c7c1e06c8 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/permission/RoleInstancePermissionService.java @@ -0,0 +1,297 @@ +/* + * SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.terrestris.shogun.lib.service.security.permission; + +import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; +import de.terrestris.shogun.lib.model.BaseEntity; +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.security.permission.PermissionCollection; +import de.terrestris.shogun.lib.model.security.permission.RoleInstancePermission; +import de.terrestris.shogun.lib.repository.security.permission.PermissionCollectionRepository; +import de.terrestris.shogun.lib.repository.security.permission.RoleInstancePermissionRepository; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Log4j2 +@Service +public class RoleInstancePermissionService extends BasePermissionService { + + @Autowired + protected PermissionCollectionRepository permissionCollectionRepository; + + @Autowired + private RoleProviderService roleProviderService; + + /** + * Returns all {@link RoleInstancePermission} for the given query arguments. + * + * @param role The role to find the permission for. + * @return The permissions + */ + public List findFor(Role role) { + log.trace("Getting all role instance permissions for role {}", role); + + List permissions = repository.findAllByRole(role); + + setAuthProviderRepresentation(permissions); + + return permissions; + } + + /** + * Get {@link RoleInstancePermission} for SHOGun role. + * + * @param entity The entity to find the permission for. + * @param role The role to find the permission for. + * @return The (optional) permission. + */ + public Optional findFor(BaseEntity entity, Role role) { + log.trace("Getting all role permissions for role with Keycloak ID {} and " + + "entity with ID {}", role.getAuthProviderId(), entity); + + Optional permission = repository.findByRoleIdAndEntityId( + role.getId(), entity.getId()); + + if (permission.isPresent()) { + setAuthProviderRepresentation(permission.get()); + } + + return permission; + } + + /** + * Get all {@link RoleInstancePermission} for the given entity. + * + * @param entity entity to get role permissions for + * @return + */ + public List findFor(BaseEntity entity) { + log.trace("Getting all role permissions for entity with ID {}", entity.getId()); + + List permissions = repository.findByEntityId(entity.getId()); + + setAuthProviderRepresentation(permissions); + + return permissions; + } + + /** + * Returns the {@link RoleInstancePermission} for the given query arguments. + * + * @param entity The entity to find the permission for. + * @param permissionCollectionType The permissionCollectionType to find the permission for. + * @return The (optional) permission. + */ + public List findFor(BaseEntity entity, PermissionCollectionType permissionCollectionType) { + + log.trace("Getting all role permissions for entity with ID {} and permission " + + "collection type {}", entity.getId(), permissionCollectionType); + + List permissions = repository + .findByEntityAndPermissionCollectionType(entity.getId(), permissionCollectionType); + + setAuthProviderRepresentation(permissions); + + return permissions; + } + + /** + * Returns the {@link Role} that has the ADMIN permission on the given entity. + * + * @param entity The entity to find the owner role for. + * @return The (optional) role. + */ + public List findOwner(BaseEntity entity) { + + log.trace("Getting the owner roles of entity with ID {}", entity.getId()); + + List roleInstancePermission = + this.findFor(entity, PermissionCollectionType.ADMIN); + + if (roleInstancePermission.isEmpty()) { + log.debug("No role instance permission candidate found."); + + return null; + } + + List owners = roleInstancePermission.stream() + .map(RoleInstancePermission::getRole) + .collect(Collectors.toList()); + + return owners; + } + + /** + * Return {@link PermissionCollection} for {@link BaseEntity} and {@link Role} + * + * @param entity The entity to use in filter + * @param role The role to use in filter + * @return {@link PermissionCollection} for {@link BaseEntity} and {@link Role} + */ + public PermissionCollection findPermissionCollectionFor(BaseEntity entity, Role role) { + Optional roleInstancePermission = this.findFor(entity, role); + + return getPermissionCollection(roleInstancePermission); + } + + /** + * Sets the given {@link PermissionCollectionType} for the given entity and role. + * + * @param persistedEntity The entity to set the permission for. + * @param role The role to set the permission for. + * @param permissionCollectionType The permission to set. + */ + public void setPermission(BaseEntity persistedEntity, Role role, PermissionCollectionType permissionCollectionType) { + Optional permissionCollection = permissionCollectionRepository + .findByName(permissionCollectionType); + + if (permissionCollection.isEmpty()) { + throw new RuntimeException("Could not find requested permission collection"); + } + + clearExistingPermission(role, permissionCollection.get(), persistedEntity); + + RoleInstancePermission roleInstancePermission = new RoleInstancePermission(); + roleInstancePermission.setRole(role); + roleInstancePermission.setEntityId(persistedEntity.getId()); + roleInstancePermission.setPermission(permissionCollection.get()); + + repository.save(roleInstancePermission); + } + + /** + * Sets the given {@link PermissionCollectionType} for the given entities and role. + * + * @param persistedEntityList A collection of entities to set permission for. + * @param role The role to set the permission for. + * @param permissionCollectionType The permission collection type (e.g. READ, READ_WRITE) to set. + */ + public void setPermission( + List persistedEntityList, + Role role, + PermissionCollectionType permissionCollectionType + ) { + Optional permissionCollection = permissionCollectionRepository + .findByName(permissionCollectionType); + + if (permissionCollection.isEmpty()) { + throw new RuntimeException("Could not find requested permission collection"); + } + + List roleInstancePermissionsToSave = new ArrayList<>(); + + persistedEntityList.forEach(e -> { + clearExistingPermission(role, permissionCollection.get(), e); + RoleInstancePermission roleInstancePermission = new RoleInstancePermission(); + roleInstancePermission.setRole(role); + roleInstancePermission.setEntityId(e.getId()); + roleInstancePermission.setPermission(permissionCollection.get()); + roleInstancePermissionsToSave.add(roleInstancePermission); + }); + + repository.saveAll(roleInstancePermissionsToSave); + } + + /** + * Clears the given {@link PermissionCollection} for the given target combination. + * + * @param role The role to clear the permission for. + * @param permissionCollection The permission collection to clear. + * @param entity The entity to clear the permission for. + */ + private void clearExistingPermission(Role role, PermissionCollection permissionCollection, BaseEntity entity) { + Optional existingPermission = findFor(entity, role); + + // Check if there is already an existing permission set on the entity. + if (existingPermission.isPresent()) { + log.debug("Permission is already set for entity with ID {} and role with " + + "Keycloak ID {}: {}", entity.getId(), role.getAuthProviderId(), permissionCollection); + + // Remove the existing one + repository.delete(existingPermission.get()); + + log.debug("Removed the permission"); + } + } + + /** + * Deletes all {@link RoleInstancePermission} for the given entity. + * + * @param persistedEntity The entity to clear the permissions for. + */ + public void deleteAllFor(BaseEntity persistedEntity) { + List roleInstancePermissions = this.findFor(persistedEntity); + + repository.deleteAll(roleInstancePermissions); + + log.info("Successfully deleted all role instance permissions for entity with ID {}", + persistedEntity.getId()); + log.trace("Deleted entity: {}", persistedEntity); + } + + /** + * Deletes the {@link RoleInstancePermission} for the given entity and role. + * + * @param persistedEntity The entity to clear the permissions for. + * @param role The role to clear the permission for. + */ + public void deleteFor(BaseEntity persistedEntity, Role role) { + Optional roleInstancePermission = this.findFor(persistedEntity, role); + + if (roleInstancePermission.isPresent()) { + repository.delete(roleInstancePermission.get()); + + log.info("Successfully deleted the role instance permission for entity with ID {} and role {}.", + persistedEntity.getId(), role.getId()); + } else { + log.warn("Could not delete the role instance permission. The requested permission does not exist."); + } + } + + /** + * Helper function to get the {@link PermissionCollection} from a given + * class permission. If no collection is available, it returns an empty + * list. + * + * @param rolePermission The rolePermission to get the permissions from. + * @return The collection (may be empty). + */ + private PermissionCollection getPermissionCollection(Optional rolePermission) { + if (rolePermission.isPresent()) { + return rolePermission.get().getPermission(); + } + + return new PermissionCollection(); + } + + private void setAuthProviderRepresentation(RoleInstancePermission permission) { + roleProviderService.setTransientRepresentations(permission.getRole()); + } + + private void setAuthProviderRepresentation(List permissions) { + permissions.forEach((roleInstancePermission -> setAuthProviderRepresentation(roleInstancePermission))); + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/RoleProviderService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/RoleProviderService.java new file mode 100644 index 000000000..0415e3d49 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/RoleProviderService.java @@ -0,0 +1,31 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2024-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.service.security.provider; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.User; + +import java.util.List; + +public interface RoleProviderService { + + void setTransientRepresentations(Role role); + + List getRolesForUser(User user); + + Role findOrCreateByProviderId(String providerRoleId); +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakRoleProviderService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakRoleProviderService.java new file mode 100644 index 000000000..c06823dd8 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakRoleProviderService.java @@ -0,0 +1,99 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2022-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.service.security.provider.keycloak; + +import de.terrestris.shogun.lib.model.Role; +import de.terrestris.shogun.lib.model.User; +import de.terrestris.shogun.lib.repository.RoleRepository; +import de.terrestris.shogun.lib.service.security.provider.RoleProviderService; +import de.terrestris.shogun.lib.util.KeycloakUtil; +import lombok.extern.log4j.Log4j2; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * NOTE: Make sure not to use services here, else the security checks will not run on them due to circular + * references. + */ +@ConditionalOnExpression("${keycloak.enabled:true}") +@Log4j2 +@Component +public class KeycloakRoleProviderService implements RoleProviderService { + + @Autowired + KeycloakUtil keycloakUtil; + + @Autowired + RoleRepository roleRepository; + + @Override + public void setTransientRepresentations(Role role) { + try { + RoleRepresentation roleRepresentation = keycloakUtil.getRoleRepresentation(role); + role.setProviderDetails(roleRepresentation); + } catch (Exception e) { + log.warn("Could not get the RoleRepresentation for role with SHOGun ID {} and " + + "Keycloak ID {}. This may happen if the role is not available in Keycloak.", + role.getId(), role.getAuthProviderId()); + log.trace("Full stack trace: ", e); + } + } + + @Override + public List getRolesForUser(User user) { + List roles = new ArrayList<>(); + + try { + List rolesA = keycloakUtil.getKeycloakUserRoles(user); + + roles = rolesA.stream() + .map(role -> roleRepository.findByAuthProviderId(role.getId())) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + } catch (Exception e) { + log.error("Error while fetching roles for user with SHOGun ID {} and Keycloak ID {}", + user.getId(), user.getAuthProviderId()); + log.trace("Full stack trace: ", e); + } + + return roles; + } + + @Override + public Role findOrCreateByProviderId(String providerRoleId) { + Optional> roleOptional = (Optional) roleRepository.findByAuthProviderId(providerRoleId); + Role role = roleOptional.orElse(null); + + if (role == null) { + role = new Role(null, providerRoleId, null); + roleRepository.save(role); + + log.info("Role with Keycloak ID {} did not yet exist in the SHOGun DB and was therefore created.", providerRoleId); + return role; + } + + return role; + } +} diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakUserProviderService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakUserProviderService.java index f76e87911..ad38c63ba 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakUserProviderService.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/security/provider/keycloak/KeycloakUserProviderService.java @@ -155,6 +155,7 @@ public Optional> getUserFromAuthentication(Authenticati } String keycloakUserId = getKeycloakUserIdFromAuthentication(authentication); + return (Optional) userRepository.findByAuthProviderId(keycloakUserId); } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/KeycloakUtil.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/KeycloakUtil.java index b49abb77d..eca6bd935 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/KeycloakUtil.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/KeycloakUtil.java @@ -17,6 +17,7 @@ package de.terrestris.shogun.lib.util; import de.terrestris.shogun.lib.model.Group; +import de.terrestris.shogun.lib.model.Role; import de.terrestris.shogun.lib.model.User; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; @@ -154,6 +155,12 @@ public RolesResource getRoles() { return keycloakRealm.roles(); } + public RoleRepresentation getRoleRepresentation(Role role) { + RoleByIdResource roleByIdResource = keycloakRealm.rolesById(); + + return roleByIdResource.getRole(role.getAuthProviderId()); + } + public boolean isUserInGroup(User user, Group group) { UserResource kcUser = this.getUserResource(user); return kcUser.groups().stream() @@ -212,6 +219,7 @@ public List getKeycloakUserGroups(User public List getKeycloakUserRoles(User user) { UserResource userResource = this.getUserResource(user); List roles = new ArrayList<>(); + try { roles = userResource.roles().getAll().getRealmMappings(); } catch (Exception e) { @@ -220,6 +228,7 @@ public List getKeycloakUserRoles(User us user.getId(), user.getAuthProviderId()); log.trace("Full stack trace: ", e); } + return roles; }