Moved roles from entities to enum

Added power level to roles
Implemented role assignment in frontend
This commit is contained in:
grimsi
2024-09-29 03:23:15 +02:00
parent 71aab506d5
commit a6e2965923
26 changed files with 329 additions and 168 deletions
@@ -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<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<Selection>();
const [error, setError] = useState<string>();
useEffect(() => {
setSelectedRoles(rolesToSelection(user.roles!));
UserEndpoint.getAvailableRoles().then((availableRoles) => {
setAvailableRoles(availableRoles!.map((role) => ({id: role!.toString()})));
});
}, []);
function rolesToSelection(roles: Array<string | undefined>): 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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
<ModalBody className="flex flex-col gap-2">
<Select
items={availableRoles}
selectionMode="multiple"
selectedKeys={selectedRoles}
onSelectionChange={setSelectedRoles}
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}
</div>
);
}}
>
{(role) => (
<SelectItem key={role.id} textValue={role.id}>
<RoleChip key={role.id} role={role.id}/>
</SelectItem>
)}
</Select>
{error &&
<small className="text-danger">{error}</small>
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRoles}>
Assign roles
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -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 (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
{roleToRoleName(role)}
</Chip>
);
}
@@ -1,5 +1,4 @@
import {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react"; import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
import {DotsThreeVertical} from "@phosphor-icons/react"; import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
@@ -10,10 +9,14 @@ import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDel
import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal"; import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto"; import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; 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 }) { export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure(); const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure(); const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true); const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]); const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]); const [dropdownItems, setDropdownItems] = useState<any[]>([]);
@@ -40,13 +43,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
if (auth.state.user?.username === user.username) return false; if (auth.state.user?.username === user.username) return false;
// User should not be able to delete the SUPERADMIN // 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) // 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 // 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() { async function resetPassword() {
@@ -79,6 +82,11 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!), onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar" label: "Remove avatar"
}, },
{
key: "assignRoles",
onPress: roleAssignmentModal.onOpen,
label: "Assign roles"
},
{ {
key: "resetPassword", key: "resetPassword",
onPress: resetPassword, onPress: resetPassword,
@@ -108,9 +116,9 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p> <p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p> <p className="text-sm">{user.email}</p>
{user.roles?.map((role) => {user.roles?.map((role) => (
<Chip key={role} size="sm" radius="sm" <RoleChip role={role as Role}/>
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)} ))}
</div> </div>
</div> </div>
@@ -138,6 +146,8 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen} <PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange} onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/> token={passwordResetToken as TokenDto}/>
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
user={user}/>
</> </>
) )
} }
@@ -3,12 +3,12 @@ package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto 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.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@Endpoint @Endpoint
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class ConfigEndpoint( class ConfigEndpoint(
private val config: ConfigService private val config: ConfigService
) { ) {
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -2,9 +2,7 @@ package de.grimsi.gameyfin.core
import de.grimsi.gameyfin.setup.SetupService import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.users.UserService 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.entities.User
import de.grimsi.gameyfin.users.persistence.RoleRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -17,7 +15,6 @@ import java.net.InetAddress
@Service @Service
@Transactional @Transactional
class SetupDataLoader( class SetupDataLoader(
private val roleRepository: RoleRepository,
private val userService: UserService, private val userService: UserService,
private val setupService: SetupService, private val setupService: SetupService,
private val env: Environment private val env: Environment
@@ -29,39 +26,16 @@ class SetupDataLoader(
if (setupService.isSetupCompleted()) return if (setupService.isSetupCompleted()) return
log.info { "Looks like this is the first time you're starting Gameyfin." } 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) { if ("dev" in env.activeProfiles) {
log.info { "We will now set up some data for local development..." }
setupUsers() 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" }
} }
fun setupRoles() { val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http"
log.info { "Setting up roles..." } log.info { "Visit $protocol://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
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
} }
fun setupUsers() { fun setupUsers() {
@@ -72,27 +46,29 @@ class SetupDataLoader(
password = "admin", password = "admin",
email = "admin@gameyfin.org", email = "admin@gameyfin.org",
emailConfirmed = true, emailConfirmed = true,
enabled = true enabled = true,
roles = setOf(Role.SUPERADMIN)
) )
registerUserIfNotFound(superadmin, Roles.SUPERADMIN) registerUserIfNotFound(superadmin)
val user = User( val user = User(
username = "user", username = "user",
password = "user", password = "user",
email = "user@gameyfin.org", email = "user@gameyfin.org",
emailConfirmed = true, emailConfirmed = true,
enabled = true enabled = true,
roles = setOf(Role.USER)
) )
registerUserIfNotFound(user, Roles.USER) registerUserIfNotFound(user)
log.info { "User setup completed." } log.info { "User setup completed." }
} }
fun registerUserIfNotFound(user: User, role: Roles) { fun registerUserIfNotFound(user: User) {
if (userService.existsByUsername(user.username)) return if (userService.existsByUsername(user.username)) return
userService.registerOrUpdateUser(user, role) userService.registerOrUpdateUser(user)
} }
} }
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint 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 de.grimsi.gameyfin.libraries.entities.Library
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -9,7 +9,7 @@ import jakarta.annotation.security.RolesAllowed
class LibraryEndpoint( class LibraryEndpoint(
private val libraryService: LibraryService private val libraryService: LibraryService
) { ) {
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getAllLibraries(): Collection<Library> { fun getAllLibraries(): Collection<Library> {
return libraryService.getAllLibraries() return libraryService.getAllLibraries()
} }
@@ -2,12 +2,12 @@ package de.grimsi.gameyfin.logs
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@Endpoint @Endpoint
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class LogEndpoint( class LogEndpoint(
private val logService: LogService private val logService: LogService
) { ) {
@@ -2,11 +2,11 @@ package de.grimsi.gameyfin.messages
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@Endpoint @Endpoint
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class MessageEndpoint( class MessageEndpoint(
private val messageService: MessageService private val messageService: MessageService
) { ) {
@@ -1,10 +1,10 @@
package de.grimsi.gameyfin.messages.templates package de.grimsi.gameyfin.messages.templates
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@Endpoint @Endpoint
class MessageTemplateEndpoint( class MessageTemplateEndpoint(
private val messageTemplateService: MessageTemplateService private val messageTemplateService: MessageTemplateService
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.setup 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.RoleService
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserInfoDto import de.grimsi.gameyfin.users.dto.UserInfoDto
@@ -20,7 +20,7 @@ class SetupService(
* 1. At least one user with "Super Admin" role * 1. At least one user with "Super Admin" role
*/ */
fun isSetupCompleted(): Boolean { fun isSetupCompleted(): Boolean {
return roleService.getUserCountForRole(Roles.SUPERADMIN) > 0 return roleService.getUserCountForRole(Role.SUPERADMIN) > 0
} }
/** /**
@@ -31,11 +31,11 @@ class SetupService(
username = registration.username, username = registration.username,
password = registration.password, password = registration.password,
email = registration.email, 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) return userService.toUserInfo(user)
} }
} }
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.system package de.grimsi.gameyfin.system
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@Endpoint @Endpoint
@@ -9,7 +9,7 @@ class SystemEndpoint(
private val systemService: SystemService private val systemService: SystemService
) { ) {
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun restart() { fun restart() {
systemService.restart() systemService.restart()
} }
@@ -1,9 +1,10 @@
package de.grimsi.gameyfin.users package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.Role import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.RoleRepository import de.grimsi.gameyfin.users.persistence.UserRepository
import jakarta.transaction.Transactional import jakarta.transaction.Transactional
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
@@ -12,7 +13,7 @@ import org.springframework.stereotype.Service
@Service @Service
@Transactional @Transactional
class RoleService( class RoleService(
private val roleRepository: RoleRepository private val userRepository: UserRepository
) { ) {
companion object { companion object {
@@ -20,26 +21,37 @@ class RoleService(
const val INTERNAL_ROLE_PREFIX = "ROLE_" const val INTERNAL_ROLE_PREFIX = "ROLE_"
} }
fun getAllRoles(): List<Role> {
return Role.entries
}
/** /**
* @return the number of registered users with a given role * @return the number of registered users with a given role
* @return 0 if a role does not exist
*/ */
fun getUserCountForRole(role: Roles): Int { fun getUserCountForRole(role: Role): Int {
val r = roleRepository.findByRolename(role.roleName) ?: return 0 return userRepository.countUserByRolesContains(role)
return r.users.size
} }
fun toRoles(roles: Collection<Roles>): Set<Role> { fun getHighestRole(roles: Collection<Role>): Role {
return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }.toSet() return roles.maxByOrNull { it.powerLevel } ?: Role.USER
} }
fun toRole(role: Roles): Role { fun getHighestRoleFromAuthorities(authorities: Collection<GrantedAuthority>): Role {
return roleRepository.findByRolename(role.roleName) return getHighestRole(authoritiesToRoles(authorities))
?: throw RuntimeException("Role ${role.roleName} does not exist") }
fun getRolesBelowUser(user: User): List<Role> {
val highestUserRole = getHighestRole(user.roles)
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
}
fun getRolesBelowAuth(auth: Authentication): List<Role> {
val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.safeValueOf(it.authority) })
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
} }
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> { fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> {
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<List<String>>("roles") val roles = userInfo.getClaim<List<String>>("roles")
roles.asSequence().mapNotNull { roles.asSequence().mapNotNull {
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority( if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
it.replace( it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
SSO_ROLE_PREFIX,
INTERNAL_ROLE_PREFIX
)
) )
else null else null
} }
} }
.toSet() .toSet()
// Add USER role if no roles are present
if (mappedAuthorities.isEmpty()) { if (mappedAuthorities.isEmpty()) {
mappedAuthorities.plus(SimpleGrantedAuthority(Roles.Names.USER)) mappedAuthorities.plus(SimpleGrantedAuthority(Role.Names.USER))
} }
return mappedAuthorities return mappedAuthorities
@@ -1,9 +1,10 @@
package de.grimsi.gameyfin.users package de.grimsi.gameyfin.users
import com.vaadin.hilla.Endpoint 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.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@@ -12,7 +13,8 @@ import org.springframework.security.core.context.SecurityContextHolder
@Endpoint @Endpoint
class UserEndpoint( class UserEndpoint(
private val userService: UserService private val userService: UserService,
private val roleService: RoleService
) { ) {
@PermitAll @PermitAll
fun existsByMail(email: String): Boolean { fun existsByMail(email: String): Boolean {
@@ -25,18 +27,18 @@ class UserEndpoint(
return userService.getUserInfo(auth) return userService.getUserInfo(auth)
} }
@RolesAllowed(Roles.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
}
@PermitAll @PermitAll
fun updateUser(updates: UserUpdateDto) { fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.updateUser(auth.name, updates) userService.updateUser(auth.name, updates)
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
}
@RolesAllowed(Role.Names.ADMIN)
fun updateUserByName(username: String, updates: UserUpdateDto) { fun updateUserByName(username: String, updates: UserUpdateDto) {
userService.updateUser(username, updates) userService.updateUser(username, updates)
} }
@@ -47,8 +49,24 @@ class UserEndpoint(
userService.deleteUser(auth.name) userService.deleteUser(auth.name)
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun deleteUserByName(username: String) { fun deleteUserByName(username: String) {
userService.deleteUser(username) userService.deleteUser(username)
} }
@RolesAllowed(Role.Names.ADMIN)
fun getAvailableRoles(): List<String> {
return roleService.getAllRoles().map { it.roleName }
}
@RolesAllowed(Role.Names.ADMIN)
fun getRolesBelow(): List<String> {
val auth: Authentication = SecurityContextHolder.getContext().authentication
return roleService.getRolesBelowAuth(auth).map { it.roleName }
}
@RolesAllowed(Role.Names.ADMIN)
fun assignRoles(username: String, roles: List<String>): RoleAssignmentResult {
return userService.assignRoles(username, roles)
}
} }
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.config.ConfigProperties import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService 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.Utils
import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent 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.dto.UserUpdateDto
import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService
import de.grimsi.gameyfin.users.entities.Avatar 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.entities.User
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
import de.grimsi.gameyfin.users.persistence.AvatarContentStore import de.grimsi.gameyfin.users.persistence.AvatarContentStore
import de.grimsi.gameyfin.users.persistence.UserRepository import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging 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.Authentication
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority 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.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.core.userdetails.UsernameNotFoundException
@@ -88,7 +89,9 @@ class UserService(
if (principal is OidcUser) { if (principal is OidcUser) {
val oidcUser = User(principal) val oidcUser = User(principal)
val userInfoDto = toUserInfo(oidcUser) 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 return userInfoDto
} }
@@ -128,16 +131,7 @@ class UserService(
} }
fun registerOrUpdateUser(user: User): User { fun registerOrUpdateUser(user: User): User {
return userRepository.save(user) user.password = passwordEncoder.encode(user.password)
}
fun registerOrUpdateUser(user: User, role: Roles): User {
return registerOrUpdateUser(user, listOf(role))
}
fun registerOrUpdateUser(user: User, roles: List<Roles>): User {
user.password?.let { user.password = passwordEncoder.encode(it) }
user.roles = roleService.toRoles(roles)
return userRepository.save(user) return userRepository.save(user)
} }
@@ -162,7 +156,7 @@ class UserService(
password = passwordEncoder.encode(registration.password), password = passwordEncoder.encode(registration.password),
email = registration.email, email = registration.email,
enabled = !adminNeedsToApprove, enabled = !adminNeedsToApprove,
roles = roleService.toRoles(listOf(Roles.USER)) roles = setOf(Role.USER)
) )
user = userRepository.save(user) user = userRepository.save(user)
@@ -186,7 +180,7 @@ class UserService(
email = email, email = email,
emailConfirmed = true, emailConfirmed = true,
enabled = true, enabled = true,
roles = roleService.toRoles(listOf(Roles.USER)) roles = setOf(Role.USER)
) )
return userRepository.save(user) return userRepository.save(user)
@@ -224,6 +218,31 @@ class UserService(
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl())) eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
} }
fun assignRoles(username: String, roleNames: List<String>): 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) { fun deleteUser(username: String) {
val user = userByUsername(username) val user = userByUsername(username)
userRepository.delete(user) userRepository.delete(user)
@@ -237,12 +256,12 @@ class UserService(
isEnabled = user.enabled, isEnabled = user.enabled,
hasAvatar = user.avatar != null, hasAvatar = user.avatar != null,
managedBySso = user.oidcProviderId != null, managedBySso = user.oidcProviderId != null,
roles = user.roles.map { r -> r.rolename } roles = user.roles
) )
} }
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> { private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.rolename) } return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
} }
private fun userByUsername(username: String): User { private fun userByUsername(username: String): User {
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.users.avatar 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 de.grimsi.gameyfin.users.UserService
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -35,7 +35,7 @@ class AvatarController(
userService.deleteAvatar(auth.name) userService.deleteAvatar(auth.name)
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@PostMapping("/avatar/deleteByName") @PostMapping("/avatar/deleteByName")
fun deleteAvatarByName(@RequestParam("name") name: String) { fun deleteAvatarByName(@RequestParam("name") name: String) {
userService.deleteAvatar(name) userService.deleteAvatar(name)
@@ -1,5 +1,7 @@
package de.grimsi.gameyfin.users.dto package de.grimsi.gameyfin.users.dto
import de.grimsi.gameyfin.core.Role
data class UserInfoDto( data class UserInfoDto(
val username: String, val username: String,
val managedBySso: Boolean, val managedBySso: Boolean,
@@ -7,5 +9,5 @@ data class UserInfoDto(
val emailConfirmed: Boolean, val emailConfirmed: Boolean,
val isEnabled: Boolean, val isEnabled: Boolean,
val hasAvatar: Boolean, val hasAvatar: Boolean,
var roles: List<String> var roles: Set<Role>
) )
@@ -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<User> = emptyList()
)
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.users.entities package de.grimsi.gameyfin.users.entities
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.security.EncryptionConverter import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.annotation.Nullable import jakarta.annotation.Nullable
import jakarta.persistence.* import jakarta.persistence.*
@@ -35,12 +36,8 @@ class User(
@Nullable @Nullable
var avatar: Avatar? = null, var avatar: Avatar? = null,
@ManyToMany(fetch = FetchType.EAGER) @ElementCollection(targetClass = Role::class, fetch = FetchType.EAGER)
@JoinTable( @Enumerated(EnumType.STRING)
name = "users_roles",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
)
var roles: Set<Role> = emptySet() var roles: Set<Role> = emptySet()
) { ) {
@@ -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
}
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.passwordreset
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint 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.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
@@ -21,7 +21,7 @@ class PasswordResetEndpoint(
// No return value to prevent enumeration attacks // No return value to prevent enumeration attacks
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun createPasswordResetTokenForUser(username: String): TokenDto { fun createPasswordResetTokenForUser(username: String): TokenDto {
return passwordResetService.generate(username) return passwordResetService.generate(username)
} }
@@ -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<Role, Long> {
fun findByRolename(roleName: String): Role?
}
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.users.persistence package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
@@ -9,4 +10,5 @@ interface UserRepository : JpaRepository<User, Long> {
fun findByUsername(userName: String): User? fun findByUsername(userName: String): User?
fun findByEmail(email: String): User? fun findByEmail(email: String): User?
fun findByOidcProviderId(oidcProviderId: String): User? fun findByOidcProviderId(oidcProviderId: String): User?
fun countUserByRolesContains(role: Role): Int
} }
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.registration
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint 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.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
@@ -37,12 +37,12 @@ class RegistrationEndpoint(
return invitationService.getAssociatedEmail(token) return invitationService.getAssociatedEmail(token)
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun confirmRegistration(username: String) { fun confirmRegistration(username: String) {
userService.confirmRegistration(username) userService.confirmRegistration(username)
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun createInvitation(email: String): TokenDto { fun createInvitation(email: String): TokenDto {
return invitationService.createInvitation(email) return invitationService.createInvitation(email)
} }
@@ -3,13 +3,18 @@
package de.grimsi.gameyfin.users.util 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 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 } return role.roleName in this.authorities.map { a -> a.authority }
} }
fun UserDetails.isAdmin(): Boolean { fun UserDetails.isAdmin(): Boolean {
return hasRole(Roles.SUPERADMIN) || hasRole(Roles.ADMIN) return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN)
} }