diff --git a/src/main/frontend/components/general/AssignRolesModal.tsx b/src/main/frontend/components/general/AssignRolesModal.tsx new file mode 100644 index 0000000..70ff422 --- /dev/null +++ b/src/main/frontend/components/general/AssignRolesModal.tsx @@ -0,0 +1,118 @@ +import React, {useEffect, useState} from "react"; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Select, + SelectedItems, + Selection, + SelectItem +} from "@nextui-org/react"; +import {UserEndpoint} from "Frontend/generated/endpoints"; +import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; +import RoleChip from "Frontend/components/general/RoleChip"; +import RoleAssignmentResult from "Frontend/generated/de/grimsi/gameyfin/users/enums/RoleAssignmentResult"; + +interface AssignRolesModalProps { + isOpen: boolean; + onOpenChange: () => void; + user: UserInfoDto; +} + +interface Role { + id: string; +} + +export default function AssignRolesModal({ + isOpen, + onOpenChange, + user + }: AssignRolesModalProps) { + const [availableRoles, setAvailableRoles] = useState([]); + const [selectedRoles, setSelectedRoles] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + setSelectedRoles(rolesToSelection(user.roles!)); + UserEndpoint.getAvailableRoles().then((availableRoles) => { + setAvailableRoles(availableRoles!.map((role) => ({id: role!.toString()}))); + }); + }, []); + + function rolesToSelection(roles: Array): Selection { + return new Set(roles.map((role) => role!.toString())); + } + + async function assignRoles() { + if (!selectedRoles) return; + + let selectedRolesArray = Array.from(selectedRoles).map((role) => role.toString()); + let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray); + if (!result) return; + switch (result) { + case RoleAssignmentResult.SUCCESS: + window.location.reload(); + break; + case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH: + setError("Power level of user too high"); + break; + case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH: + setError("Power level of assigned role too high"); + break; + default: + setError("An error occurred"); + break; + } + } + + return ( + + + {(onClose) => ( + <> + Assign roles to {user.username} + + + {error && + {error} + } + + + + + + + )} + + + ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/RoleChip.tsx b/src/main/frontend/components/general/RoleChip.tsx new file mode 100644 index 0000000..aad3b76 --- /dev/null +++ b/src/main/frontend/components/general/RoleChip.tsx @@ -0,0 +1,10 @@ +import {Chip} from "@nextui-org/react"; +import {roleToColor, roleToRoleName} from "Frontend/util/utils"; + +export default function RoleChip({role}: { role: string }) { + return ( + + {roleToRoleName(role)} + + ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/UserManagementCard.tsx b/src/main/frontend/components/general/UserManagementCard.tsx index 1641990..f12e74a 100644 --- a/src/main/frontend/components/general/UserManagementCard.tsx +++ b/src/main/frontend/components/general/UserManagementCard.tsx @@ -1,5 +1,4 @@ -import {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react"; -import {roleToColor, roleToRoleName} from "Frontend/util/utils"; +import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react"; import {DotsThreeVertical} from "@phosphor-icons/react"; import {useAuth} from "Frontend/util/auth"; import {useEffect, useState} from "react"; @@ -10,10 +9,14 @@ import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDel import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal"; import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto"; import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; +import RoleChip from "Frontend/components/general/RoleChip"; +import AssignRolesModal from "Frontend/components/general/AssignRolesModal"; +import Role from "Frontend/generated/de/grimsi/gameyfin/core/Role"; export function UserManagementCard({user}: { user: UserInfoDto }) { const userDeletionConfirmationModal = useDisclosure(); const passwordResetTokenModal = useDisclosure(); + const roleAssignmentModal = useDisclosure(); const [userEnabled, setUserEnabled] = useState(true); const [disabledKeys, setDisabledKeys] = useState([]); const [dropdownItems, setDropdownItems] = useState([]); @@ -40,13 +43,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) { if (auth.state.user?.username === user.username) return false; // User should not be able to delete the SUPERADMIN - if (user.roles?.includes("ROLE_SUPERADMIN")) return false; + if (user.roles?.includes(Role.SUPERADMIN)) return false; // Superadmins can delete anyone excluding themselves (and other superadmins if there are any) - if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true; + if (auth.state.user?.roles?.includes(Role.SUPERADMIN)) return true; // Admins should be only allowed to delete other users, not other admins - return !user.roles?.includes("ROLE_ADMIN"); + return !user.roles?.includes(Role.ADMIN); } async function resetPassword() { @@ -79,6 +82,11 @@ export function UserManagementCard({user}: { user: UserInfoDto }) { onPress: () => AvatarEndpoint.removeAvatarByName(user.username!), label: "Remove avatar" }, + { + key: "assignRoles", + onPress: roleAssignmentModal.onOpen, + label: "Assign roles" + }, { key: "resetPassword", onPress: resetPassword, @@ -108,9 +116,9 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {

{user.username}

{user.email}

- {user.roles?.map((role) => - {roleToRoleName(role!)})} + {user.roles?.map((role) => ( + + ))}
@@ -138,6 +146,8 @@ export function UserManagementCard({user}: { user: UserInfoDto }) { + ) } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt index a6d1f32..060cfd4 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt @@ -3,12 +3,12 @@ package de.grimsi.gameyfin.config import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.config.dto.ConfigEntryDto import de.grimsi.gameyfin.config.dto.ConfigValuePairDto -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed @Endpoint -@RolesAllowed(Roles.Names.ADMIN) +@RolesAllowed(Role.Names.ADMIN) class ConfigEndpoint( private val config: ConfigService ) { diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/Role.kt b/src/main/kotlin/de/grimsi/gameyfin/core/Role.kt new file mode 100644 index 0000000..7fac369 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/core/Role.kt @@ -0,0 +1,32 @@ +package de.grimsi.gameyfin.core + +import com.fasterxml.jackson.annotation.JsonValue +import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX + +enum class Role(val roleName: String, val powerLevel: Int) { + + SUPERADMIN(Names.SUPERADMIN, 3), + ADMIN(Names.ADMIN, 2), + USER(Names.USER, 1); + + @JsonValue + override fun toString(): String { + return this.roleName + } + + companion object { + fun safeValueOf(type: String): Role? { + val enumString = type.removePrefix(INTERNAL_ROLE_PREFIX) + return java.lang.Enum.valueOf(Role::class.java, enumString) + } + } + + // necessary for the ability to use the Roles class in the @RolesAllowed annotation + class Names { + companion object { + const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN" + const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN" + const val USER = "${INTERNAL_ROLE_PREFIX}USER" + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/Roles.kt b/src/main/kotlin/de/grimsi/gameyfin/core/Roles.kt deleted file mode 100644 index 6d8872e..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/core/Roles.kt +++ /dev/null @@ -1,18 +0,0 @@ -package de.grimsi.gameyfin.core - -import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX - -enum class Roles(val roleName: String) { - SUPERADMIN(Names.SUPERADMIN), - ADMIN(Names.ADMIN), - USER(Names.USER); - - // necessary for the ability to use the Roles class in the @RolesAllowed annotation - class Names { - companion object { - const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN" - const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN" - const val USER = "${INTERNAL_ROLE_PREFIX}USER" - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/SetupDataLoader.kt b/src/main/kotlin/de/grimsi/gameyfin/core/SetupDataLoader.kt index 899412d..5294d05 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/SetupDataLoader.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/SetupDataLoader.kt @@ -2,9 +2,7 @@ package de.grimsi.gameyfin.core import de.grimsi.gameyfin.setup.SetupService import de.grimsi.gameyfin.users.UserService -import de.grimsi.gameyfin.users.entities.Role import de.grimsi.gameyfin.users.entities.User -import de.grimsi.gameyfin.users.persistence.RoleRepository import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.transaction.Transactional import org.springframework.boot.context.event.ApplicationReadyEvent @@ -17,7 +15,6 @@ import java.net.InetAddress @Service @Transactional class SetupDataLoader( - private val roleRepository: RoleRepository, private val userService: UserService, private val setupService: SetupService, private val env: Environment @@ -29,39 +26,16 @@ class SetupDataLoader( if (setupService.isSetupCompleted()) return log.info { "Looks like this is the first time you're starting Gameyfin." } - log.info { "We will now set up some data..." } - - setupRoles() if ("dev" in env.activeProfiles) { + log.info { "We will now set up some data for local development..." } setupUsers() + log.info { "Setup completed..." } } - log.info { "Setup completed..." } - log.info { "Visit http://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" } - } + val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http" - fun setupRoles() { - - log.info { "Setting up roles..." } - - createRoleIfNotFound(Roles.SUPERADMIN.roleName) - createRoleIfNotFound(Roles.ADMIN.roleName) - createRoleIfNotFound(Roles.USER.roleName) - - log.info { "Role setup completed." } - } - - fun createRoleIfNotFound(name: String): Role { - log.info { "Creating role $name" } - - var role: Role? = roleRepository.findByRolename(name) - - if (role == null) { - role = Role(name) - roleRepository.save(role) - } - return role + log.info { "Visit $protocol://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" } } fun setupUsers() { @@ -72,27 +46,29 @@ class SetupDataLoader( password = "admin", email = "admin@gameyfin.org", emailConfirmed = true, - enabled = true + enabled = true, + roles = setOf(Role.SUPERADMIN) ) - registerUserIfNotFound(superadmin, Roles.SUPERADMIN) + registerUserIfNotFound(superadmin) val user = User( username = "user", password = "user", email = "user@gameyfin.org", emailConfirmed = true, - enabled = true + enabled = true, + roles = setOf(Role.USER) ) - registerUserIfNotFound(user, Roles.USER) + registerUserIfNotFound(user) log.info { "User setup completed." } } - fun registerUserIfNotFound(user: User, role: Roles) { + fun registerUserIfNotFound(user: User) { if (userService.existsByUsername(user.username)) return - userService.registerOrUpdateUser(user, role) + userService.registerOrUpdateUser(user) } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt index 407a20a..18e7fa7 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -1,7 +1,7 @@ package de.grimsi.gameyfin.libraries import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.libraries.entities.Library import jakarta.annotation.security.RolesAllowed @@ -9,7 +9,7 @@ import jakarta.annotation.security.RolesAllowed class LibraryEndpoint( private val libraryService: LibraryService ) { - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun getAllLibraries(): Collection { return libraryService.getAllLibraries() } diff --git a/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt index 3243a28..3a08a0e 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt @@ -2,12 +2,12 @@ package de.grimsi.gameyfin.logs import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.RolesAllowed import reactor.core.publisher.Flux @Endpoint -@RolesAllowed(Roles.Names.ADMIN) +@RolesAllowed(Role.Names.ADMIN) class LogEndpoint( private val logService: LogService ) { diff --git a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt index c3569cf..79376f3 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageEndpoint.kt @@ -2,11 +2,11 @@ package de.grimsi.gameyfin.messages import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.RolesAllowed @Endpoint -@RolesAllowed(Roles.Names.ADMIN) +@RolesAllowed(Role.Names.ADMIN) class MessageEndpoint( private val messageService: MessageService ) { diff --git a/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt index 791c828..abf429c 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplateEndpoint.kt @@ -1,10 +1,10 @@ package de.grimsi.gameyfin.messages.templates import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.RolesAllowed -@RolesAllowed(Roles.Names.ADMIN) +@RolesAllowed(Role.Names.ADMIN) @Endpoint class MessageTemplateEndpoint( private val messageTemplateService: MessageTemplateService diff --git a/src/main/kotlin/de/grimsi/gameyfin/setup/SetupService.kt b/src/main/kotlin/de/grimsi/gameyfin/setup/SetupService.kt index 19e3057..6205001 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/setup/SetupService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/setup/SetupService.kt @@ -1,6 +1,6 @@ package de.grimsi.gameyfin.setup -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.users.RoleService import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.dto.UserInfoDto @@ -20,7 +20,7 @@ class SetupService( * 1. At least one user with "Super Admin" role */ fun isSetupCompleted(): Boolean { - return roleService.getUserCountForRole(Roles.SUPERADMIN) > 0 + return roleService.getUserCountForRole(Role.SUPERADMIN) > 0 } /** @@ -31,11 +31,11 @@ class SetupService( username = registration.username, password = registration.password, email = registration.email, - roles = setOf(roleService.toRole(Roles.SUPERADMIN)), - enabled = true + enabled = true, + roles = setOf(Role.SUPERADMIN) ) - val user = userService.registerOrUpdateUser(superAdmin, Roles.SUPERADMIN) + val user = userService.registerOrUpdateUser(superAdmin) return userService.toUserInfo(user) } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt index 766679c..7a4edf2 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt @@ -1,7 +1,7 @@ package de.grimsi.gameyfin.system import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.RolesAllowed @Endpoint @@ -9,7 +9,7 @@ class SystemEndpoint( private val systemService: SystemService ) { - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun restart() { systemService.restart() } diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/RoleService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/RoleService.kt index 3a8784d..d7d93b5 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/RoleService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/RoleService.kt @@ -1,9 +1,10 @@ package de.grimsi.gameyfin.users -import de.grimsi.gameyfin.core.Roles -import de.grimsi.gameyfin.users.entities.Role -import de.grimsi.gameyfin.users.persistence.RoleRepository +import de.grimsi.gameyfin.core.Role +import de.grimsi.gameyfin.users.entities.User +import de.grimsi.gameyfin.users.persistence.UserRepository import jakarta.transaction.Transactional +import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority @@ -12,7 +13,7 @@ import org.springframework.stereotype.Service @Service @Transactional class RoleService( - private val roleRepository: RoleRepository + private val userRepository: UserRepository ) { companion object { @@ -20,26 +21,37 @@ class RoleService( const val INTERNAL_ROLE_PREFIX = "ROLE_" } + fun getAllRoles(): List { + return Role.entries + } + /** * @return the number of registered users with a given role - * @return 0 if a role does not exist */ - fun getUserCountForRole(role: Roles): Int { - val r = roleRepository.findByRolename(role.roleName) ?: return 0 - return r.users.size + fun getUserCountForRole(role: Role): Int { + return userRepository.countUserByRolesContains(role) } - fun toRoles(roles: Collection): Set { - return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }.toSet() + fun getHighestRole(roles: Collection): Role { + return roles.maxByOrNull { it.powerLevel } ?: Role.USER } - fun toRole(role: Roles): Role { - return roleRepository.findByRolename(role.roleName) - ?: throw RuntimeException("Role ${role.roleName} does not exist") + fun getHighestRoleFromAuthorities(authorities: Collection): Role { + return getHighestRole(authoritiesToRoles(authorities)) + } + + fun getRolesBelowUser(user: User): List { + val highestUserRole = getHighestRole(user.roles) + return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel } + } + + fun getRolesBelowAuth(auth: Authentication): List { + val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.safeValueOf(it.authority) }) + return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel } } fun authoritiesToRoles(authorities: Collection): Set { - return authorities.mapNotNull { a -> roleRepository.findByRolename(a.authority) }.toSet() + return authorities.mapNotNull { Role.safeValueOf(it.authority) }.toMutableSet() } /** @@ -59,18 +71,16 @@ class RoleService( val roles = userInfo.getClaim>("roles") roles.asSequence().mapNotNull { if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority( - it.replace( - SSO_ROLE_PREFIX, - INTERNAL_ROLE_PREFIX - ) + it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX) ) else null } } .toSet() + // Add USER role if no roles are present if (mappedAuthorities.isEmpty()) { - mappedAuthorities.plus(SimpleGrantedAuthority(Roles.Names.USER)) + mappedAuthorities.plus(SimpleGrantedAuthority(Role.Names.USER)) } return mappedAuthorities diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt index 69418d3..c9f6e5a 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt @@ -1,9 +1,10 @@ package de.grimsi.gameyfin.users import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.users.dto.UserInfoDto import de.grimsi.gameyfin.users.dto.UserUpdateDto +import de.grimsi.gameyfin.users.enums.RoleAssignmentResult import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.springframework.security.core.Authentication @@ -12,7 +13,8 @@ import org.springframework.security.core.context.SecurityContextHolder @Endpoint class UserEndpoint( - private val userService: UserService + private val userService: UserService, + private val roleService: RoleService ) { @PermitAll fun existsByMail(email: String): Boolean { @@ -25,18 +27,18 @@ class UserEndpoint( return userService.getUserInfo(auth) } - @RolesAllowed(Roles.Names.ADMIN) - fun getAllUsers(): List { - return userService.getAllUsers() - } - @PermitAll fun updateUser(updates: UserUpdateDto) { val auth: Authentication = SecurityContextHolder.getContext().authentication userService.updateUser(auth.name, updates) } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) + fun getAllUsers(): List { + return userService.getAllUsers() + } + + @RolesAllowed(Role.Names.ADMIN) fun updateUserByName(username: String, updates: UserUpdateDto) { userService.updateUser(username, updates) } @@ -47,8 +49,24 @@ class UserEndpoint( userService.deleteUser(auth.name) } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun deleteUserByName(username: String) { userService.deleteUser(username) } + + @RolesAllowed(Role.Names.ADMIN) + fun getAvailableRoles(): List { + return roleService.getAllRoles().map { it.roleName } + } + + @RolesAllowed(Role.Names.ADMIN) + fun getRolesBelow(): List { + val auth: Authentication = SecurityContextHolder.getContext().authentication + return roleService.getRolesBelowAuth(auth).map { it.roleName } + } + + @RolesAllowed(Role.Names.ADMIN) + fun assignRoles(username: String, roles: List): RoleAssignmentResult { + return userService.assignRoles(username, roles) + } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index a635c8b..bc574c8 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigService -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Utils import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent @@ -13,8 +13,8 @@ import de.grimsi.gameyfin.users.dto.UserRegistrationDto import de.grimsi.gameyfin.users.dto.UserUpdateDto import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService import de.grimsi.gameyfin.users.entities.Avatar -import de.grimsi.gameyfin.users.entities.Role import de.grimsi.gameyfin.users.entities.User +import de.grimsi.gameyfin.users.enums.RoleAssignmentResult import de.grimsi.gameyfin.users.persistence.AvatarContentStore import de.grimsi.gameyfin.users.persistence.UserRepository import io.github.oshai.kotlinlogging.KotlinLogging @@ -23,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException @@ -88,7 +89,9 @@ class UserService( if (principal is OidcUser) { val oidcUser = User(principal) val userInfoDto = toUserInfo(oidcUser) - userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities).map { it.authority } + userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities) + .mapNotNull { Role.safeValueOf(it.authority) } + .toSet() return userInfoDto } @@ -128,16 +131,7 @@ class UserService( } fun registerOrUpdateUser(user: User): User { - return userRepository.save(user) - } - - fun registerOrUpdateUser(user: User, role: Roles): User { - return registerOrUpdateUser(user, listOf(role)) - } - - fun registerOrUpdateUser(user: User, roles: List): User { - user.password?.let { user.password = passwordEncoder.encode(it) } - user.roles = roleService.toRoles(roles) + user.password = passwordEncoder.encode(user.password) return userRepository.save(user) } @@ -162,7 +156,7 @@ class UserService( password = passwordEncoder.encode(registration.password), email = registration.email, enabled = !adminNeedsToApprove, - roles = roleService.toRoles(listOf(Roles.USER)) + roles = setOf(Role.USER) ) user = userRepository.save(user) @@ -186,7 +180,7 @@ class UserService( email = email, emailConfirmed = true, enabled = true, - roles = roleService.toRoles(listOf(Roles.USER)) + roles = setOf(Role.USER) ) return userRepository.save(user) @@ -224,6 +218,31 @@ class UserService( eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl())) } + fun assignRoles(username: String, roleNames: List): RoleAssignmentResult { + val currentUser = SecurityContextHolder.getContext().authentication + val targetUser = userByUsername(username) + + val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel + val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel + + if (currentUserLevel <= targetUserLevel) { + log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" } + return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH + } + + val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) } + val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel + + if (currentUserLevel <= newAssignedRolesLevel) { + log.error { "User ${currentUser.name} tried to assign roles with higher or equal power level than their own" } + return RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH + } + + targetUser.roles = newAssignedRoles.toMutableSet() + userRepository.save(targetUser) + return RoleAssignmentResult.SUCCESS + } + fun deleteUser(username: String) { val user = userByUsername(username) userRepository.delete(user) @@ -237,12 +256,12 @@ class UserService( isEnabled = user.enabled, hasAvatar = user.avatar != null, managedBySso = user.oidcProviderId != null, - roles = user.roles.map { r -> r.rolename } + roles = user.roles ) } private fun toAuthorities(roles: Collection): List { - return roles.map { r -> SimpleGrantedAuthority(r.rolename) } + return roles.map { r -> SimpleGrantedAuthority(r.roleName) } } private fun userByUsername(username: String): User { diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt index fa58d24..800c9ae 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt @@ -1,6 +1,6 @@ package de.grimsi.gameyfin.users.avatar -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.users.UserService import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed @@ -35,7 +35,7 @@ class AvatarController( userService.deleteAvatar(auth.name) } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) @PostMapping("/avatar/deleteByName") fun deleteAvatarByName(@RequestParam("name") name: String) { userService.deleteAvatar(name) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt index 590133f..b38a23a 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt @@ -1,5 +1,7 @@ package de.grimsi.gameyfin.users.dto +import de.grimsi.gameyfin.core.Role + data class UserInfoDto( val username: String, val managedBySso: Boolean, @@ -7,5 +9,5 @@ data class UserInfoDto( val emailConfirmed: Boolean, val isEnabled: Boolean, val hasAvatar: Boolean, - var roles: List + var roles: Set ) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/Role.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/Role.kt deleted file mode 100644 index fbfa22c..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/Role.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.grimsi.gameyfin.users.entities - -import jakarta.persistence.* -import jakarta.validation.constraints.NotNull - - -@Entity -class Role( - @NotNull - @Column(unique = true) - var rolename: String, - - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - var id: Long? = null, - - @ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER) - var users: Collection = emptyList() -) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt index 2d3d47d..a574ded 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt @@ -1,5 +1,6 @@ package de.grimsi.gameyfin.users.entities +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.security.EncryptionConverter import jakarta.annotation.Nullable import jakarta.persistence.* @@ -35,12 +36,8 @@ class User( @Nullable var avatar: Avatar? = null, - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable( - name = "users_roles", - joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")], - inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")] - ) + @ElementCollection(targetClass = Role::class, fetch = FetchType.EAGER) + @Enumerated(EnumType.STRING) var roles: Set = emptySet() ) { diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/enums/RoleAssignmentResult.kt b/src/main/kotlin/de/grimsi/gameyfin/users/enums/RoleAssignmentResult.kt new file mode 100644 index 0000000..3ec2984 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/enums/RoleAssignmentResult.kt @@ -0,0 +1,7 @@ +package de.grimsi.gameyfin.users.enums + +enum class RoleAssignmentResult { + SUCCESS, + TARGET_POWER_LEVEL_TOO_HIGH, + ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetEndpoint.kt index f6b89f5..4ebf23f 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/passwordreset/PasswordResetEndpoint.kt @@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.passwordreset import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.shared.token.TokenDto import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.users.UserService @@ -21,7 +21,7 @@ class PasswordResetEndpoint( // No return value to prevent enumeration attacks } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun createPasswordResetTokenForUser(username: String): TokenDto { return passwordResetService.generate(username) } diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/RoleRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/RoleRepository.kt deleted file mode 100644 index 5752dde..0000000 --- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/RoleRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.grimsi.gameyfin.users.persistence - -import de.grimsi.gameyfin.users.entities.Role -import org.springframework.data.jpa.repository.JpaRepository - -interface RoleRepository : JpaRepository { - fun findByRolename(roleName: String): Role? -} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt index 271f3cd..feda205 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt @@ -1,5 +1,6 @@ package de.grimsi.gameyfin.users.persistence +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.users.entities.User import org.springframework.data.jpa.repository.JpaRepository @@ -9,4 +10,5 @@ interface UserRepository : JpaRepository { fun findByUsername(userName: String): User? fun findByEmail(email: String): User? fun findByOidcProviderId(oidcProviderId: String): User? + fun countUserByRolesContains(role: Role): Int } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt index ad313f0..00c359b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/registration/RegistrationEndpoint.kt @@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.registration import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.shared.token.TokenDto import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.users.UserService @@ -37,12 +37,12 @@ class RegistrationEndpoint( return invitationService.getAssociatedEmail(token) } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun confirmRegistration(username: String) { userService.confirmRegistration(username) } - @RolesAllowed(Roles.Names.ADMIN) + @RolesAllowed(Role.Names.ADMIN) fun createInvitation(email: String): TokenDto { return invitationService.createInvitation(email) } diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/util/UserDetailsExtensions.kt b/src/main/kotlin/de/grimsi/gameyfin/users/util/UserDetailsExtensions.kt index 6158c61..c49ae52 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/util/UserDetailsExtensions.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/util/UserDetailsExtensions.kt @@ -3,13 +3,18 @@ package de.grimsi.gameyfin.users.util -import de.grimsi.gameyfin.core.Roles +import de.grimsi.gameyfin.core.Role +import de.grimsi.gameyfin.users.entities.User import org.springframework.security.core.userdetails.UserDetails -fun UserDetails.hasRole(role: Roles): Boolean { +fun User.hasRole(role: Role): Boolean { + return role.roleName in this.roles.map { r -> r.roleName } +} + +fun UserDetails.hasRole(role: Role): Boolean { return role.roleName in this.authorities.map { a -> a.authority } } fun UserDetails.isAdmin(): Boolean { - return hasRole(Roles.SUPERADMIN) || hasRole(Roles.ADMIN) + return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN) } \ No newline at end of file