Skip to content

Commit

Permalink
Add machine accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
barreiro committed May 20, 2024
1 parent 116e978 commit f27ad57
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ public interface UserService {
@Path("search")
@Blocking
@Operation(description="Search for user(s) with an optional query condition.")
List<UserData> searchUsers(@Parameter(required = true, name="query", description = "filter users by username (case insensitive)",
example = "user") @QueryParam("query") String query);
List<UserData> searchUsers(@Parameter(required = true, name="query", description = "filter users by username (case insensitive)", example = "user") @QueryParam("query") String query);

@POST
@Path("info")
Expand Down Expand Up @@ -73,8 +72,7 @@ List<UserData> searchUsers(@Parameter(required = true, name="query", description
@POST
@Path("team/{team}/members")
@Blocking
void updateTeamMembers(@PathParam("team") String team,
@RequestBody(required = true) Map<String, List<String>> roles);
void updateTeamMembers(@PathParam("team") String team, @RequestBody(required = true) Map<String, List<String>> roles);

@GET
@Path("allTeams")
Expand All @@ -101,6 +99,13 @@ void updateTeamMembers(@PathParam("team") String team,
@Blocking
void updateAdministrators(@RequestBody(required = true) List<String> 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
Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

public enum UserRole {

ADMIN, HORREUM_SYSTEM;
ADMIN, HORREUM_SYSTEM, MACHINE;

@Override @RolesValue public String toString() {
return super.toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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> 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));
Expand All @@ -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.")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -130,9 +133,15 @@ private void addTeamMembership(UserInfo userInfo, String teamName, TeamRole role
roles.forEach((username, teamRoles) -> {
Optional<UserInfo> 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<TeamMembership> 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()));
});
Expand Down Expand Up @@ -211,6 +220,16 @@ private List<UserInfo> 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()`
*/
Expand Down Expand Up @@ -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<Object[], String[]> {
@Override public String[] apply(Object[] objects) {
return new String[] {(String) objects[0]};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,8 @@ private void createRole(String roleName, Set<String> compositeRoles) {
throw ServiceException.serverError("Cannot find admin role");
}
}

@Override public void setPassword(String username, String password) {
throw ServiceException.serverError("Set password in keycloak");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ public interface UserBackEnd {

void updateAdministrators(List<String> newAdmins);

void setPassword(String username, String password);
}
121 changes: 121 additions & 0 deletions horreum-web/src/domain/user/MachineAccounts.tsx
Original file line number Diff line number Diff line change
@@ -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<Team>(createTeam(defaultTeam))
const [resetCounter, setResetCounter] = useState(0)
const {alerting} = useContext(AppContext) as AppContextType;
const [machineAccounts, setMachineUsers] = useState<UserData[]>([])

const [currentUser, setCurrentUser] = useState<string>()
const [newPassword, setNewPassword] = useState<string>()
const teamMembersFuncs = useRef<TeamMembersFunctions>()

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 (
<>
<Form isHorizontal>
<FormGroup label="Select team" fieldId="team">
<TeamSelect
includeGeneral={false}
selection={team}
teamsSelector={managedTeamsSelector}
onSelect={anotherTeam => setTeam(anotherTeam)}
/>
</FormGroup>
<FormGroup label="Machine Accounts" fieldId="machines" onClick={e => e.preventDefault()}>
<DataList aria-label="Machine accounts" isCompact={true}>
{machineAccounts.map((u, i) =>
<DataListItem key={"machine-account-" + i} aria-labelledby={"machine-account-" + i}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key={"content" + i}>
<span id={"machine-account-" + i}>{userName(u)}</span>
</DataListCell>,
]}
/>
<DataListAction key={"action" + i} aria-labelledby={"reset-password" + i}
aria-label={"reset-password-" + i} id={"reset-password" + i}>
<Button
variant="danger" size="sm" key={"reset-password" + i}
onClick={() => {
if (confirm("Are you sure you want to reset password of user " + userName(u) + "?")) {
userApi.resetPassword(u.username).then(
newPassword => {
setCurrentUser(userName(u))
setNewPassword(newPassword)
},
error => alerting.dispatchError(error, "RESET_PASSWORD", "Failed to reset password")
)
}
}}
>
Reset Password
</Button>
</DataListAction>
</DataListItemRow>
</DataListItem>
)}
</DataList>
</FormGroup>
</Form>
<Modal
isOpen={newPassword != undefined}
title={"New password of " + currentUser}
aria-label="New password"
variant="small"
onClose={() => {
setNewPassword(undefined)
}}>
<ClipboardCopy isReadOnly>{newPassword}</ClipboardCopy>
</Modal>
</>
)
}
7 changes: 5 additions & 2 deletions horreum-web/src/domain/user/NewUserModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {useState, useEffect, useContext} from "react"
import { useDispatch } from "react-redux"
import { Button,
Checkbox,
Form,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -145,6 +145,9 @@ export default function NewUserModal(props: NewUserModalProps) {
<ListItem>
<Checkbox id="manager" isChecked={manager} onChange={(_event, val) => setManager(val)} label="Manager" />
</ListItem>
<ListItem>
<Checkbox id="machine" isChecked={machine} onChange={(_event, val) => setMachine(val)} label="Machine" />
</ListItem>
</List>
</FormGroup>
</Form>
Expand Down
Loading

0 comments on commit f27ad57

Please sign in to comment.