diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java index b0dd4d687..36c16cd28 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/internal/services/UserService.java @@ -35,8 +35,7 @@ public interface UserService { @Path("search") @Blocking @Operation(description="Search for user(s) with an optional query condition.") - List searchUsers(@Parameter(required = true, name="query", description = "filter users by username (case insensitive)", - example = "user") @QueryParam("query") String query); + List searchUsers(@Parameter(required = true, name="query", description = "filter users by username (case insensitive)", example = "user") @QueryParam("query") String query); @POST @Path("info") @@ -73,8 +72,7 @@ List searchUsers(@Parameter(required = true, name="query", description @POST @Path("team/{team}/members") @Blocking - void updateTeamMembers(@PathParam("team") String team, - @RequestBody(required = true) Map> roles); + void updateTeamMembers(@PathParam("team") String team, @RequestBody(required = true) Map> roles); @GET @Path("allTeams") @@ -101,6 +99,13 @@ void updateTeamMembers(@PathParam("team") String team, @Blocking void updateAdministrators(@RequestBody(required = true) List administrators); + @POST + @Path("resetPassword") + @Consumes("text/plain") + @Produces("text/plain") + @Blocking + String resetPassword(@RequestBody(required = true) String username); + // this is a simplified copy of org.keycloak.representations.idm.UserRepresentation class UserData { @NotNull @@ -110,16 +115,22 @@ class UserData { public String firstName; public String lastName; public String email; + public boolean machine; public UserData() { } public UserData(String id, String username, String firstName, String lastName, String email) { + this(id, username, firstName, lastName, email, false); + } + + public UserData(String id, String username, String firstName, String lastName, String email, boolean machine) { this.id = id; this.username = username; this.firstName = firstName; this.lastName = lastName; this.email = email; + this.machine = machine; } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java index 9835cc1e7..30adfb57d 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserInfo.java @@ -76,7 +76,7 @@ public UserInfo(String username) { } public void setPassword(String clearPassword) { - password = BcryptUtil.bcryptHash(clearPassword); + password = clearPassword == null ? null : BcryptUtil.bcryptHash(clearPassword); } @Override public boolean equals(Object o) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserRole.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserRole.java index 4e0319e3a..202a279cb 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserRole.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/entity/user/UserRole.java @@ -4,7 +4,7 @@ public enum UserRole { - ADMIN, HORREUM_SYSTEM; + ADMIN, HORREUM_SYSTEM, MACHINE; @Override @RolesValue public String toString() { return super.toString(); diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java index 286bf6de1..53f47ee92 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/UserServiceImpl.java @@ -13,6 +13,7 @@ import jakarta.transaction.Transactional; import org.jboss.logging.Logger; +import java.security.SecureRandom; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -24,6 +25,7 @@ @ApplicationScoped public class UserServiceImpl implements UserService { private static final Logger LOG = Logger.getLogger(UserServiceImpl.class); + private static final int RANDOM_PASSWORD_LENGTH = 15; @Inject SecurityIdentity identity; @@ -128,6 +130,22 @@ public class UserServiceImpl implements UserService { backend.get().updateAdministrators(newAdmins); } + // @RolesAllowed({ Roles.ADMIN, Roles.MANAGER }) + @Override public String resetPassword(String username) { + List userData = backend.get().info(List.of(username)); + if (userData.isEmpty()) { + throw ServiceException.notFound(format("User with username {0} not found", username)); + } + if (!userData.get(0).machine) { + throw ServiceException.badRequest("Can only reset password for machine account"); + } + // todo: verify authenticated user is manager for username + String newPassword = new SecureRandom().ints(RANDOM_PASSWORD_LENGTH, '0', 'z' + 1).collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); + backend.get().setPassword(username, newPassword); + LOG.infov("{0} reset password of user {1}", identity.getPrincipal().getName(), username); + return newPassword; + } + private void userIsManagerForTeam(String team) { if (!identity.getRoles().contains(Roles.ADMIN) && !identity.hasRole(team.substring(0, team.length() - 4) + Roles.MANAGER)) { throw ServiceException.badRequest(format("This user is not a manager for team {0}", team)); @@ -147,8 +165,8 @@ private static void validateNewUser(NewUser user) { } } - private static String validateTeamName(String unsafeTean) { - String team = Util.destringify(unsafeTean); + private static String validateTeamName(String unsafeTeam) { + String team = Util.destringify(unsafeTeam); if (team == null || team.isBlank()) { throw ServiceException.badRequest("No team name!!!"); } else if (team.startsWith("horreum.")) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java index ae5050a39..ebb546a4f 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/DatabaseUserBackend.java @@ -39,7 +39,7 @@ public class DatabaseUserBackend implements UserBackEnd { private static final Logger LOG = Logger.getLogger(DatabaseUserBackend.class); private static UserService.UserData toUserInfo(UserInfo info) { - return new UserService.UserData("", info.username, info.firstName, info.lastName, info.email); + return new UserService.UserData("", info.username, info.firstName, info.lastName, info.email, info.roles.contains(UserRole.MACHINE)); } private static String removeTeamSuffix(String team) { @@ -86,11 +86,14 @@ private static String removeTeamSuffix(String team) { addTeamMembership(userInfo, teamName, TeamRole.TEAM_UPLOADER); } else if ("manager".equals(role)) { addTeamMembership(userInfo, teamName, TeamRole.TEAM_MANAGER); - } else { + } else if (!"machine".equals(role)) { LOG.infov("Dropping role {0} for user {1} {2}", role, userInfo.firstName, userInfo.lastName); } } } + if (user.roles != null && user.roles.contains("machine")) { + userInfo.roles.add(UserRole.MACHINE); + } userInfo.persist(); } @@ -130,9 +133,15 @@ private void addTeamMembership(UserInfo userInfo, String teamName, TeamRole role roles.forEach((username, teamRoles) -> { Optional user = UserInfo.findByIdOptional(username); user.ifPresent(u -> { + if (teamRoles.remove("machine")) { // deal with the "machine" role, that is a UserRole but is displayed in the UI as a TeamMembership + u.roles.add(UserRole.MACHINE); + } else { + u.roles.remove(UserRole.MACHINE); + u.setPassword(null); + } List removedMemberships = u.teams.stream().filter(t -> t.team == teamEntity && !teamRoles.contains(t.asUIRole())).toList(); removedMemberships.forEach(TeamMembership::delete); - u.teams.removeAll(removedMemberships); + removedMemberships.forEach(u.teams::remove); u.teams.addAll(teamRoles.stream().map(uiRole -> TeamMembership.getEntityManager().merge(new TeamMembership(user.get(), teamEntity, uiRole))).collect(toSet())); }); @@ -211,6 +220,16 @@ private List getAdministratorUsers() { return UserInfo.getEntityManager().createQuery(query).getResultList(); } + @Transactional + @WithRoles(fromParams = ResetPasswordParameterConverter.class) + @Override public void setPassword(String username, String password) { + UserInfo user = UserInfo.findById(username); + if (user == null) { + throw ServiceException.notFound(format("User {0} not found", username)); + } + user.setPassword(password); + } + /** * Extracts username from parameters of `createUser()` */ @@ -238,4 +257,13 @@ public static final class DeleteTeamAndMembershipsParameterConverter implements return ((Team) objects[0]).teams.stream().map(membership -> membership.user.username).toArray(String[]::new); } } + + /** + * Extract usernames from parameters of `resetPassword()` + */ + public static final class ResetPasswordParameterConverter implements Function { + @Override public String[] apply(Object[] objects) { + return new String[] {(String) objects[0]}; + } + } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java index 6a9d7ea04..309c661cd 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/KeycloakUserBackend.java @@ -341,4 +341,8 @@ private void createRole(String roleName, Set compositeRoles) { throw ServiceException.serverError("Cannot find admin role"); } } + + @Override public void setPassword(String username, String password) { + throw ServiceException.serverError("Set password in keycloak"); + } } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java index c980ee09c..824b05abf 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/user/UserBackEnd.java @@ -32,4 +32,5 @@ public interface UserBackEnd { void updateAdministrators(List newAdmins); + void setPassword(String username, String password); } diff --git a/horreum-web/src/domain/user/MachineAccounts.tsx b/horreum-web/src/domain/user/MachineAccounts.tsx new file mode 100644 index 000000000..3026470a5 --- /dev/null +++ b/horreum-web/src/domain/user/MachineAccounts.tsx @@ -0,0 +1,121 @@ +import {useContext, useEffect, useRef, useState} from "react" +import {useSelector} from "react-redux" +import { + Button, ClipboardCopy, + DataList, + DataListAction, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + Form, + FormGroup, + Modal, +} from "@patternfly/react-core" + +import {TabFunctionsRef} from "../../components/SavedTabs" +import TeamSelect, {createTeam, Team} from "../../components/TeamSelect" +import {defaultTeamSelector, managedTeamsSelector, userName} from "../../auth" +import {userApi, UserData} from "../../api"; +import {AppContext} from "../../context/appContext"; +import {AppContextType} from "../../context/@types/appContextTypes"; +import {TeamMembersFunctions} from "./TeamMembers"; + +type ManageMachineAccountsProps = { + funcs: TabFunctionsRef + onModified(modified: boolean): void +} + +export default function ManageMachineAccounts(props: ManageMachineAccountsProps) { + let defaultTeam = useSelector(defaultTeamSelector) + const managedTeams = useSelector(managedTeamsSelector) + if (!defaultTeam || !managedTeams.includes(defaultTeam)) { + defaultTeam = managedTeams.length > 0 ? managedTeams[0] : undefined + } + const [team, setTeam] = useState(createTeam(defaultTeam)) + const [resetCounter, setResetCounter] = useState(0) + const {alerting} = useContext(AppContext) as AppContextType; + const [machineAccounts, setMachineUsers] = useState([]) + + const [currentUser, setCurrentUser] = useState() + const [newPassword, setNewPassword] = useState() + const teamMembersFuncs = useRef() + + useEffect(() => { + setMachineUsers([]) + userApi.teamMembers(team.key).then( + userRolesMap => { + userApi.info(Object.keys(userRolesMap)).then( + users => setMachineUsers(users.filter(u => u.machine)), + error => alerting.dispatchError(error, "FETCH_USERS_INFO", "Failed to fetch details for users") + ) + }, + error => alerting.dispatchError(error, "FETCH_TEAM_MEMBERS", "Failed to fetch team members") + ) + }, [team, resetCounter]) + + props.funcs.current = { + save: () => teamMembersFuncs.current?.save() || Promise.resolve(), + reset: () => setResetCounter(resetCounter + 1) + } + return ( + <> +
+ + setTeam(anotherTeam)} + /> + + e.preventDefault()}> + + {machineAccounts.map((u, i) => + + + + {userName(u)} + , + ]} + /> + + + + + + )} + + +
+ { + setNewPassword(undefined) + }}> + {newPassword} + + + ) +} diff --git a/horreum-web/src/domain/user/NewUserModal.tsx b/horreum-web/src/domain/user/NewUserModal.tsx index beb424d2d..2c49afdc8 100644 --- a/horreum-web/src/domain/user/NewUserModal.tsx +++ b/horreum-web/src/domain/user/NewUserModal.tsx @@ -1,5 +1,4 @@ import {useState, useEffect, useContext} from "react" -import { useDispatch } from "react-redux" import { Button, Checkbox, Form, @@ -37,6 +36,7 @@ export default function NewUserModal(props: NewUserModalProps) { const [tester, setTester] = useState(true) const [uploader, setUploader] = useState(false) const [manager, setManager] = useState(false) + const [machine, setMachine] = useState(false) const valid = username && password && email && /^.+@.+\..+$/.test(email) useEffect(() => { setUsername(undefined) @@ -60,7 +60,7 @@ export default function NewUserModal(props: NewUserModalProps) { onClick={() => { setCreating(true) const user = { id: "", username: username || "", email, firstName, lastName } - const roles = getRoles(viewer, tester, uploader, manager) + const roles = getRoles(viewer, tester, uploader, manager, machine) userApi.createUser({ user, password, team: props.team, roles }) .then(() => { props.onCreate(user, roles) @@ -145,6 +145,9 @@ export default function NewUserModal(props: NewUserModalProps) { setManager(val)} label="Manager" /> + + setMachine(val)} label="Machine" /> + diff --git a/horreum-web/src/domain/user/TeamMembers.tsx b/horreum-web/src/domain/user/TeamMembers.tsx index 99ef06df5..c1755bb96 100644 --- a/horreum-web/src/domain/user/TeamMembers.tsx +++ b/horreum-web/src/domain/user/TeamMembers.tsx @@ -16,12 +16,13 @@ type UserPermissionsProps = { onRolesUpdate(roles: string[]): void } -export function getRoles(viewer: boolean, tester: boolean, uploader: boolean, manager: boolean) { +export function getRoles(viewer: boolean, tester: boolean, uploader: boolean, manager: boolean, machine: boolean) { const newRoles = [] if (viewer) newRoles.push("viewer") if (tester) newRoles.push("tester") if (uploader) newRoles.push("uploader") if (manager) newRoles.push("manager") + if (machine) newRoles.push("machine") return newRoles } @@ -30,6 +31,7 @@ function UserPermissions({ user, roles, onRolesUpdate }: UserPermissionsProps) { const [tester, setTester] = useState(roles.includes("tester")) const [uploader, setUploader] = useState(roles.includes("uploader")) const [manager, setManager] = useState(roles.includes("manager")) + const [machine, setMachine] = useState(roles.includes("machine")) return ( { @@ -222,6 +223,20 @@ export function UserSettings() { ) : ( <> )} + {managedTeams.length > 0 ? ( + modified} + canSave={true} + onSave={() => teamFuncsRef.current?.save() || Promise.resolve()} + onReset={() => teamFuncsRef.current?.reset()} + > + + + ) : ( + <> + )}