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 28, 2024
1 parent 88cc42e commit ee7a51e
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 22 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 @@ -55,11 +55,11 @@ public TeamMembership(UserInfo user, Team team, String uiRole) {
return false;
}
TeamMembership that = (TeamMembership) o;
return user.equals(that.user) && team.equals(that.team) && role == that.role;
return user.username.equals(that.user.username) && team.equals(that.team) && role == that.role;
}

@Override public int hashCode() {
int result = user.hashCode();
int result = user.username.hashCode();
result = 31 * result + team.hashCode();
result = 31 * result + role.hashCode();
return result;
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,11 +133,17 @@ private void addTeamMembership(UserInfo userInfo, String teamName, TeamRole role
roles.forEach((username, teamRoles) -> {
Optional<UserInfo> user = UserInfo.findByIdOptional(username);
user.ifPresent(u -> {
if (teamRoles.contains("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()));
u.teams.addAll(teamRoles.stream().filter(uiRole -> !"machine".equals(uiRole)).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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.Test;

Expand All @@ -39,6 +40,8 @@ public abstract class UserServiceAbstractTest {
// the name of the default keycloak user with "admin" role (see io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager#createUsers)
private static final String KEYCLOAK_ADMIN = "admin";

@ConfigProperty(name = "horreum.roles.provider", defaultValue = "keycloak") String provider;

@Inject UserServiceImpl userService;

/**
Expand Down Expand Up @@ -150,13 +153,13 @@ private void overrideTestSecurity(String name, Set<String> roles, Runnable runna
@TestSecurity(user = KEYCLOAK_ADMIN, roles = { Roles.ADMIN })
@Test void teamManagerTest() {
String testTeam = "managed-test-team", otherTeam = "some-team-that-does-not-exist-team";
String managedUser = "managed";
userService.addTeam(testTeam);

// getting members of a non-existing team should not throw
assertTrue(userService.teamMembers(otherTeam).isEmpty());

overrideTestSecurity("manager", Set.of(testTeam.substring(0, testTeam.length() - 4) + Roles.MANAGER), () -> {
String managedUser = "managed";

// team just created has no members
assertTrue(userService.teamMembers(testTeam).isEmpty());
Expand Down Expand Up @@ -206,6 +209,10 @@ private void overrideTestSecurity(String name, Set<String> roles, Runnable runna
assertThrows(ServiceException.class, () -> userService.updateTeamMembers(otherTeam, Map.of()));
});

// check machine account (database backend only)
userService.updateTeamMembers(testTeam, Map.of(managedUser, List.of("machine")));
assertTrue(!"database".equals(provider) || userService.info(List.of(managedUser)).get(0).machine, "User should be machine account");

userService.deleteTeam(testTeam);
}

Expand Down
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>
</>
)
}
Loading

0 comments on commit ee7a51e

Please sign in to comment.